diff --git a/.env b/.env index f405293de7..71a63171e7 100644 --- a/.env +++ b/.env @@ -78,6 +78,9 @@ TASK_RETRY_ATTEMPTS=5 # Valkey settings # If running Valkey and celery on host, use localhost, else use 'valkey' +VALKEY_SCHEME=redis +VALKEY_USERNAME= +VALKEY_PASSWORD= VALKEY_HOST=valkey VALKEY_PORT=6379 VALKEY_DB=0 diff --git a/.github/actions/setup-python-poetry/action.yml b/.github/actions/setup-python-poetry/action.yml index 62d01091d8..505ba86da6 100644 --- a/.github/actions/setup-python-poetry/action.yml +++ b/.github/actions/setup-python-poetry/action.yml @@ -26,10 +26,18 @@ runs: if: github.event_name == 'pull_request' && github.base_ref == 'master' && github.repository == 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} run: | BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - echo "Using branch: $BRANCH_NAME" - sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml + UPSTREAM="prowler-cloud/prowler" + if [ "$HEAD_REPO" != "$UPSTREAM" ]; then + echo "Fork PR detected (${HEAD_REPO}), rewriting VCS URL to fork" + sed -i "s|git+https://github.com/prowler-cloud/prowler\([^@]*\)@master|git+https://github.com/${HEAD_REPO}\1@$BRANCH_NAME|g" pyproject.toml + else + echo "Same-repo PR, using branch: $BRANCH_NAME" + sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml + fi - name: Install poetry shell: bash diff --git a/.github/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index 9360dd0953..9073e2fbcb 100644 --- a/.github/actions/trivy-scan/action.yml +++ b/.github/actions/trivy-scan/action.yml @@ -117,7 +117,10 @@ runs: INPUTS_IMAGE_TAG: ${{ inputs.image-tag }} - name: Comment scan results on PR - if: inputs.create-pr-comment == 'true' && github.event_name == 'pull_request' + if: >- + inputs.create-pr-comment == 'true' + && github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: IMAGE_NAME: ${{ inputs.image-name }} diff --git a/.github/labeler.yml b/.github/labeler.yml index 9a6b7262d0..49ec92e0d5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -67,6 +67,11 @@ provider/googleworkspace: - any-glob-to-any-file: "prowler/providers/googleworkspace/**" - any-glob-to-any-file: "tests/providers/googleworkspace/**" +provider/vercel: + - changed-files: + - any-glob-to-any-file: "prowler/providers/vercel/**" + - any-glob-to-any-file: "tests/providers/vercel/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" @@ -102,6 +107,8 @@ mutelist: - any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/googleworkspace/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/googleworkspace/lib/mutelist/**" + - any-glob-to-any-file: "prowler/providers/vercel/lib/mutelist/**" + - any-glob-to-any-file: "tests/providers/vercel/lib/mutelist/**" integration/s3: - changed-files: diff --git a/.github/scripts/test-e2e-path-resolution.sh b/.github/scripts/test-e2e-path-resolution.sh new file mode 100755 index 0000000000..8aec6a46ac --- /dev/null +++ b/.github/scripts/test-e2e-path-resolution.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash +# +# Test script for E2E test path resolution logic from ui-e2e-tests-v2.yml. +# Validates that the shell logic correctly transforms E2E_TEST_PATHS into +# Playwright-compatible paths. +# +# Usage: .github/scripts/test-e2e-path-resolution.sh + +set -euo pipefail + +# -- Colors ------------------------------------------------------------------ +RED='\033[0;31m' +GREEN='\033[0;32m' +BOLD='\033[1m' +RESET='\033[0m' + +# -- Counters ---------------------------------------------------------------- +TOTAL=0 +PASSED=0 +FAILED=0 + +# -- Temp directory setup & cleanup ------------------------------------------ +TMPDIR_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_ROOT"' EXIT + +# --------------------------------------------------------------------------- +# create_test_tree DIR [SUBDIRS_WITH_TESTS...] +# +# Creates a fake ui/tests/ tree inside DIR. +# All standard subdirs are created (empty). +# For each name in SUBDIRS_WITH_TESTS, a fake .spec.ts file is placed inside. +# --------------------------------------------------------------------------- +create_test_tree() { + local base="$1"; shift + local all_subdirs=( + auth home invitations profile providers scans + setups sign-in-base sign-up attack-paths findings + compliance browse manage-groups roles users overview + integrations + ) + + for d in "${all_subdirs[@]}"; do + mkdir -p "${base}/tests/${d}" + done + + # Populate requested subdirs with a fake test file + for d in "$@"; do + mkdir -p "${base}/tests/${d}" + touch "${base}/tests/${d}/example.spec.ts" + done +} + +# --------------------------------------------------------------------------- +# resolve_paths E2E_TEST_PATHS WORKING_DIR +# +# Extracted EXACT logic from .github/workflows/ui-e2e-tests-v2.yml lines 212-250. +# Outputs space-separated TEST_PATHS, or "SKIP" if no tests found. +# Must be run with WORKING_DIR as the cwd equivalent (we cd into it). +# --------------------------------------------------------------------------- +resolve_paths() { + local E2E_TEST_PATHS="$1" + local WORKING_DIR="$2" + + ( + cd "$WORKING_DIR" + + # --- Line 212-214: strip ui/ prefix, strip **, deduplicate --------------- + TEST_PATHS="${E2E_TEST_PATHS}" + TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u) + + # --- Line 216: drop setup helpers ---------------------------------------- + TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/' || true) + + # --- Lines 219-230: safety net for bare tests/ -------------------------- + if echo "$TEST_PATHS" | grep -qx 'tests/'; then + SPECIFIC_DIRS="" + for dir in tests/*/; do + [[ "$dir" == "tests/setups/" ]] && continue + SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n' + done + TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true) + TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}" + TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u) + fi + + # --- Lines 231-234: bail if empty ---------------------------------------- + if [[ -z "$TEST_PATHS" ]]; then + echo "SKIP" + return + fi + + # --- Lines 236-245: filter dirs with no test files ----------------------- + VALID_PATHS="" + while IFS= read -r p; do + [[ -z "$p" ]] && continue + if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then + VALID_PATHS="${VALID_PATHS}${p}"$'\n' + fi + done <<< "$TEST_PATHS" + VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$') + + # --- Lines 246-249: bail if all empty ------------------------------------ + if [[ -z "$VALID_PATHS" ]]; then + echo "SKIP" + return + fi + + # --- Line 250: final output (space-separated) --------------------------- + echo "$VALID_PATHS" | tr '\n' ' ' | sed 's/ $//' + ) +} + +# --------------------------------------------------------------------------- +# run_test NAME INPUT EXPECTED_TYPE [EXPECTED_VALUE] +# +# EXPECTED_TYPE is one of: +# "contains " — output must contain this path +# "equals " — output must exactly equal this value +# "skip" — expect SKIP (no runnable tests) +# "not_contains

" — output must NOT contain this path +# +# Multiple expectations can be specified by calling assert_* after run_test. +# For convenience, run_test supports a single assertion inline. +# --------------------------------------------------------------------------- +CURRENT_RESULT="" +CURRENT_TEST_NAME="" + +run_test() { + local name="$1" + local input="$2" + local expect_type="$3" + local expect_value="${4:-}" + + TOTAL=$((TOTAL + 1)) + CURRENT_TEST_NAME="$name" + + # Create a fresh temp tree per test + local test_dir="${TMPDIR_ROOT}/test_${TOTAL}" + mkdir -p "$test_dir" + + # Default populated dirs: scans, providers, auth, home, profile, sign-up, sign-in-base + create_test_tree "$test_dir" scans providers auth home profile sign-up sign-in-base + + CURRENT_RESULT=$(resolve_paths "$input" "$test_dir") + + _check "$expect_type" "$expect_value" +} + +# Like run_test but lets caller specify which subdirs have test files. +run_test_custom_tree() { + local name="$1" + local input="$2" + local expect_type="$3" + local expect_value="${4:-}" + shift 4 + local populated_dirs=("$@") + + TOTAL=$((TOTAL + 1)) + CURRENT_TEST_NAME="$name" + + local test_dir="${TMPDIR_ROOT}/test_${TOTAL}" + mkdir -p "$test_dir" + + create_test_tree "$test_dir" "${populated_dirs[@]}" + + CURRENT_RESULT=$(resolve_paths "$input" "$test_dir") + + _check "$expect_type" "$expect_value" +} + +_check() { + local expect_type="$1" + local expect_value="$2" + + case "$expect_type" in + skip) + if [[ "$CURRENT_RESULT" == "SKIP" ]]; then + _pass + else + _fail "expected SKIP, got: '$CURRENT_RESULT'" + fi + ;; + contains) + if [[ "$CURRENT_RESULT" == *"$expect_value"* ]]; then + _pass + else + _fail "expected to contain '$expect_value', got: '$CURRENT_RESULT'" + fi + ;; + not_contains) + if [[ "$CURRENT_RESULT" != *"$expect_value"* ]]; then + _pass + else + _fail "expected NOT to contain '$expect_value', got: '$CURRENT_RESULT'" + fi + ;; + equals) + if [[ "$CURRENT_RESULT" == "$expect_value" ]]; then + _pass + else + _fail "expected exactly '$expect_value', got: '$CURRENT_RESULT'" + fi + ;; + *) + _fail "unknown expect_type: $expect_type" + ;; + esac +} + +_pass() { + PASSED=$((PASSED + 1)) + printf '%b PASS%b %s\n' "$GREEN" "$RESET" "$CURRENT_TEST_NAME" +} + +_fail() { + FAILED=$((FAILED + 1)) + printf '%b FAIL%b %s\n' "$RED" "$RESET" "$CURRENT_TEST_NAME" + printf " %s\n" "$1" +} + +# =========================================================================== +# TEST CASES +# =========================================================================== + +echo "" +printf '%bE2E Path Resolution Tests%b\n' "$BOLD" "$RESET" +echo "==========================================" + +# 1. Normal single module +run_test \ + "1. Normal single module" \ + "ui/tests/scans/**" \ + "contains" "tests/scans/" + +# 2. Multiple modules +run_test \ + "2. Multiple modules — scans present" \ + "ui/tests/scans/** ui/tests/providers/**" \ + "contains" "tests/scans/" + +run_test \ + "2. Multiple modules — providers present" \ + "ui/tests/scans/** ui/tests/providers/**" \ + "contains" "tests/providers/" + +# 3. Broad pattern (many modules) +run_test \ + "3. Broad pattern — no bare tests/" \ + "ui/tests/auth/** ui/tests/scans/** ui/tests/providers/** ui/tests/home/** ui/tests/profile/**" \ + "not_contains" "tests/ " + +# 4. Empty directory +run_test \ + "4. Empty directory — skipped" \ + "ui/tests/attack-paths/**" \ + "skip" + +# 5. Mix of populated and empty dirs +run_test \ + "5. Mix populated+empty — scans present" \ + "ui/tests/scans/** ui/tests/attack-paths/**" \ + "contains" "tests/scans/" + +run_test \ + "5. Mix populated+empty — attack-paths absent" \ + "ui/tests/scans/** ui/tests/attack-paths/**" \ + "not_contains" "tests/attack-paths/" + +# 6. All empty directories +run_test \ + "6. All empty directories" \ + "ui/tests/attack-paths/** ui/tests/findings/**" \ + "skip" + +# 7. Setup paths filtered +run_test \ + "7. Setup paths filtered out" \ + "ui/tests/setups/**" \ + "skip" + +# 8. Bare tests/ from broad pattern — safety net expands +run_test \ + "8. Bare tests/ expands — scans present" \ + "ui/tests/**" \ + "contains" "tests/scans/" + +run_test \ + "8. Bare tests/ expands — setups excluded" \ + "ui/tests/**" \ + "not_contains" "tests/setups/" + +# 9. Bare tests/ with all empty subdirs (only setups has files) +run_test_custom_tree \ + "9. Bare tests/ — only setups has files" \ + "ui/tests/**" \ + "skip" "" \ + setups + +# 10. Duplicate paths +run_test \ + "10. Duplicate paths — deduplicated" \ + "ui/tests/scans/** ui/tests/scans/**" \ + "equals" "tests/scans/" + +# 11. Empty input +TOTAL=$((TOTAL + 1)) +CURRENT_TEST_NAME="11. Empty input" +test_dir="${TMPDIR_ROOT}/test_${TOTAL}" +mkdir -p "$test_dir" +create_test_tree "$test_dir" scans providers +CURRENT_RESULT=$(resolve_paths "" "$test_dir") +_check "skip" "" + +# 12. Trailing/leading whitespace +run_test \ + "12. Whitespace handling" \ + " ui/tests/scans/** " \ + "contains" "tests/scans/" + +# 13. Path without ui/ prefix +run_test \ + "13. Path without ui/ prefix" \ + "tests/scans/**" \ + "contains" "tests/scans/" + +# 14. Setup mixed with valid paths — only valid pass through +run_test \ + "14. Setups + valid — setups filtered" \ + "ui/tests/setups/** ui/tests/scans/**" \ + "contains" "tests/scans/" + +run_test \ + "14. Setups + valid — setups absent" \ + "ui/tests/setups/** ui/tests/scans/**" \ + "not_contains" "tests/setups/" + +# =========================================================================== +# SUMMARY +# =========================================================================== + +echo "" +echo "==========================================" +if [[ "$FAILED" -eq 0 ]]; then + printf '%b%bAll tests passed: %d/%d%b\n' "$GREEN" "$BOLD" "$PASSED" "$TOTAL" "$RESET" +else + printf '%b%b%d/%d passed, %d FAILED%b\n' "$RED" "$BOLD" "$PASSED" "$TOTAL" "$FAILED" "$RESET" +fi +echo "" + +exit "$FAILED" diff --git a/.github/test-impact.yml b/.github/test-impact.yml index 7322fb61a7..7c290eeaa9 100644 --- a/.github/test-impact.yml +++ b/.github/test-impact.yml @@ -177,6 +177,14 @@ modules: - tests/providers/llm/** e2e: [] + - name: sdk-vercel + match: + - prowler/providers/vercel/** + - prowler/compliance/vercel/** + tests: + - tests/providers/vercel/** + e2e: [] + # ============================================ # SDK - Lib modules # ============================================ @@ -224,8 +232,24 @@ modules: tests: - api/src/backend/api/tests/test_views.py e2e: - # API view changes can break UI - - ui/tests/** + # All E2E test suites (explicit to avoid triggering auth setups in tests/setups/) + - ui/tests/auth/** + - ui/tests/sign-in/** + - ui/tests/sign-up/** + - ui/tests/sign-in-base/** + - ui/tests/scans/** + - ui/tests/providers/** + - ui/tests/findings/** + - ui/tests/compliance/** + - ui/tests/invitations/** + - ui/tests/roles/** + - ui/tests/users/** + - ui/tests/integrations/** + - ui/tests/resources/** + - ui/tests/profile/** + - ui/tests/lighthouse/** + - ui/tests/home/** + - ui/tests/attack-paths/** - name: api-serializers match: @@ -234,8 +258,24 @@ modules: tests: - api/src/backend/api/tests/** e2e: - # Serializer changes affect API responses → UI - - ui/tests/** + # All E2E test suites (explicit to avoid triggering auth setups in tests/setups/) + - ui/tests/auth/** + - ui/tests/sign-in/** + - ui/tests/sign-up/** + - ui/tests/sign-in-base/** + - ui/tests/scans/** + - ui/tests/providers/** + - ui/tests/findings/** + - ui/tests/compliance/** + - ui/tests/invitations/** + - ui/tests/roles/** + - ui/tests/users/** + - ui/tests/integrations/** + - ui/tests/resources/** + - ui/tests/profile/** + - ui/tests/lighthouse/** + - ui/tests/home/** + - ui/tests/attack-paths/** - name: api-filters match: @@ -407,8 +447,24 @@ modules: - ui/components/ui/** tests: [] e2e: - # Shared components can affect any E2E - - ui/tests/** + # All E2E test suites (explicit to avoid triggering auth setups in tests/setups/) + - ui/tests/auth/** + - ui/tests/sign-in/** + - ui/tests/sign-up/** + - ui/tests/sign-in-base/** + - ui/tests/scans/** + - ui/tests/providers/** + - ui/tests/findings/** + - ui/tests/compliance/** + - ui/tests/invitations/** + - ui/tests/roles/** + - ui/tests/users/** + - ui/tests/integrations/** + - ui/tests/resources/** + - ui/tests/profile/** + - ui/tests/lighthouse/** + - ui/tests/home/** + - ui/tests/attack-paths/** - name: ui-attack-paths match: diff --git a/.github/workflows/api-bump-version.yml b/.github/workflows/api-bump-version.yml index 720defc22d..7ddbf195f5 100644 --- a/.github/workflows/api-bump-version.yml +++ b/.github/workflows/api-bump-version.yml @@ -27,8 +27,13 @@ jobs: patch_version: ${{ steps.detect.outputs.patch_version }} current_api_version: ${{ steps.get_api_version.outputs.current_api_version }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -79,8 +84,13 @@ jobs: contents: read pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -118,7 +128,7 @@ jobs: git --no-pager diff - name: Create PR for next API minor version to master - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -137,7 +147,7 @@ jobs: By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. - name: Checkout version branch - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }} persist-credentials: false @@ -177,7 +187,7 @@ jobs: git --no-pager diff - name: Create PR for first API patch version to version branch - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -204,8 +214,13 @@ jobs: contents: read pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -255,7 +270,7 @@ jobs: git --no-pager diff - name: Create PR for next API patch version to version branch - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} diff --git a/.github/workflows/api-code-quality.yml b/.github/workflows/api-code-quality.yml index 88f7d1bce0..68928833a2 100644 --- a/.github/workflows/api-code-quality.yml +++ b/.github/workflows/api-code-quality.yml @@ -32,15 +32,25 @@ jobs: working-directory: ./api steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + pypi.org:443 + files.pythonhosted.org:443 + api.github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | api/** diff --git a/.github/workflows/api-codeql.yml b/.github/workflows/api-codeql.yml index cb8c6f05ec..3832900a89 100644 --- a/.github/workflows/api-codeql.yml +++ b/.github/workflows/api-codeql.yml @@ -41,18 +41,30 @@ jobs: - 'python' steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + release-assets.githubusercontent.com:443 + uploads.github.com:443 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/api-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/api-container-build-push.yml b/.github/workflows/api-container-build-push.yml index e6c84fb124..0cd3b82071 100644 --- a/.github/workflows/api-container-build-push.yml +++ b/.github/workflows/api-container-build-push.yml @@ -18,9 +18,6 @@ on: required: true type: string -permissions: - contents: read - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -43,7 +40,14 @@ jobs: timeout-minutes: 5 outputs: short-sha: ${{ steps.set-short-sha.outputs.short-sha }} + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + - name: Calculate short SHA id: set-short-sha run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT @@ -55,9 +59,16 @@ jobs: timeout-minutes: 5 outputs: message-ts: ${{ steps.slack-notification.outputs.ts }} + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -94,13 +105,39 @@ jobs: packages: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + _http._tcp.deb.debian.org:443 + aka.ms:443 + auth.docker.io:443 + cdn.powershellgallery.com:443 + dc.services.visualstudio.com:443 + debian.map.fastlydns.net:80 + files.pythonhosted.org:443 + github.com:443 + powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 + production.cloudflare.docker.com:443 + pypi.org:443 + registry-1.docker.io:443 + release-assets.githubusercontent.com:443 + www.powershellgallery.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - name: Pin prowler SDK to latest master commit + if: github.event_name == 'push' + run: | + LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git refs/heads/master | cut -f1) + sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml + - name: Login to DockerHub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -111,7 +148,7 @@ jobs: - name: Build and push API container for ${{ matrix.arch }} id: container-push if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ${{ env.WORKING_DIRECTORY }} push: true @@ -126,17 +163,26 @@ jobs: needs: [setup, container-build-push] if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success' runs-on: ubuntu-latest + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + release-assets.githubusercontent.com:443 + registry-1.docker.io:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 - name: Login to DockerHub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Create and push manifests for push event if: github.event_name == 'push' run: | @@ -161,7 +207,7 @@ jobs: - name: Install regctl if: always() - uses: regclient/actions/regctl-installer@f61d18f46c86af724a9c804cb9ff2a6fec741c7c # main + uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main - name: Cleanup intermediate architecture tags if: always() @@ -178,9 +224,16 @@ jobs: needs: [setup, notify-release-started, container-build-push, create-manifest] runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -221,6 +274,13 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + - name: Trigger API deployment uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index b516a25bfd..14c163e89a 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -27,15 +27,22 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: api/Dockerfile @@ -65,15 +72,39 @@ jobs: pull-requests: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + mirror.gcr.io:443 + check.trivy.dev:443 + github.com:443 + registry-1.docker.io:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 + debian.map.fastlydns.net:80 + release-assets.githubusercontent.com:443 + objects.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + www.powershellgallery.com:443 + aka.ms:443 + cdn.powershellgallery.com:443 + _http._tcp.deb.debian.org:443 + powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 + get.trivy.dev:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: api/** files_ignore: | @@ -88,7 +119,7 @@ jobs: - name: Build container for ${{ matrix.arch }} if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ${{ env.API_WORKING_DIR }} push: false diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index 5814af4549..9cd6b14623 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -32,15 +32,28 @@ jobs: working-directory: ./api steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + pypi.org:443 + files.pythonhosted.org:443 + github.com:443 + auth.safetycli.com:443 + pyup.io:443 + data.safetycli.com:443 + api.github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | api/** @@ -64,8 +77,9 @@ jobs: - name: Safety if: steps.check-changes.outputs.any_changed == 'true' - run: poetry run safety check --ignore 79023,79027 + run: poetry run safety check --ignore 79023,79027,86217 # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 + # TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0` - name: Vulture if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 4f4e814463..ce00b03108 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -22,6 +22,9 @@ env: POSTGRES_USER: prowler_user POSTGRES_PASSWORD: prowler POSTGRES_DB: postgres-db + VALKEY_SCHEME: redis + VALKEY_USERNAME: "" + VALKEY_PASSWORD: "" VALKEY_HOST: localhost VALKEY_PORT: 6379 VALKEY_DB: 0 @@ -72,15 +75,31 @@ jobs: --health-retries 5 steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + pypi.org:443 + files.pythonhosted.org:443 + cli.codecov.io:443 + keybase.io:443 + ingest.codecov.io:443 + storage.googleapis.com:443 + o26192.ingest.us.sentry.io:443 + api.github.com:443 + + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | api/** diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 79e578ffae..feb8c7c9bf 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -27,6 +27,13 @@ jobs: pull-requests: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + - name: Check labels id: label_check uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65 diff --git a/.github/workflows/ci-zizmor.yml b/.github/workflows/ci-zizmor.yml index 1ffb6b00fa..5962b01efd 100644 --- a/.github/workflows/ci-zizmor.yml +++ b/.github/workflows/ci-zizmor.yml @@ -33,8 +33,18 @@ jobs: actions: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + ghcr.io:443 + pkg-containers.githubusercontent.com:443 + api.github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/comment-label-update.yml b/.github/workflows/comment-label-update.yml index 9bddd6a9dd..3f5f3bb01f 100644 --- a/.github/workflows/comment-label-update.yml +++ b/.github/workflows/comment-label-update.yml @@ -19,6 +19,11 @@ jobs: pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Remove 'status/awaiting-response' label env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/conventional-commit.yml b/.github/workflows/conventional-commit.yml index 58e1653b74..a1195ef389 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -25,6 +25,11 @@ jobs: pull-requests: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Check PR title format uses: agenthunt/conventional-commit-checker-action@f1823f632e95a64547566dcd2c7da920e67117ad # v2.0.1 with: diff --git a/.github/workflows/create-backport-label.yml b/.github/workflows/create-backport-label.yml index 32bc3759fd..334406f76f 100644 --- a/.github/workflows/create-backport-label.yml +++ b/.github/workflows/create-backport-label.yml @@ -22,6 +22,11 @@ jobs: issues: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Create backport label for minor releases env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docs-bump-version.yml b/.github/workflows/docs-bump-version.yml index 899abd0f0e..e8c33ae53e 100644 --- a/.github/workflows/docs-bump-version.yml +++ b/.github/workflows/docs-bump-version.yml @@ -27,8 +27,13 @@ jobs: patch_version: ${{ steps.detect.outputs.patch_version }} current_docs_version: ${{ steps.get_docs_version.outputs.current_docs_version }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -79,8 +84,13 @@ jobs: contents: read pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -114,7 +124,7 @@ jobs: git --no-pager diff - name: Create PR for documentation update to master - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -137,7 +147,7 @@ jobs: By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. - name: Checkout version branch - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }} persist-credentials: false @@ -174,7 +184,7 @@ jobs: git --no-pager diff - name: Create PR for documentation update to version branch - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -204,8 +214,13 @@ jobs: contents: read pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -245,7 +260,7 @@ jobs: git --no-pager diff - name: Create PR for documentation update to version branch - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} diff --git a/.github/workflows/find-secrets.yml b/.github/workflows/find-secrets.yml index 236765d069..af0a9c7e78 100644 --- a/.github/workflows/find-secrets.yml +++ b/.github/workflows/find-secrets.yml @@ -22,8 +22,17 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + ghcr.io:443 + pkg-containers.githubusercontent.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml new file mode 100644 index 0000000000..a0a24c2516 --- /dev/null +++ b/.github/workflows/helm-chart-checks.yml @@ -0,0 +1,53 @@ +name: 'Helm: Chart Checks' +# DISCLAIMER: This workflow is not maintained by the Prowler team. Refer to contrib/k8s/helm/prowler-app for the source code. +on: + push: + branches: + - 'master' + - 'v5.*' + paths: + - 'contrib/k8s/helm/prowler-app/**' + pull_request: + branches: + - 'master' + - 'v5.*' + paths: + - 'contrib/k8s/helm/prowler-app/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CHART_PATH: contrib/k8s/helm/prowler-app + +jobs: + helm-lint: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 + + - name: Update chart dependencies + run: helm dependency update ${{ env.CHART_PATH }} + + - name: Lint Helm chart + run: helm lint ${{ env.CHART_PATH }} + + - name: Validate Helm chart template rendering + run: helm template prowler ${{ env.CHART_PATH }} diff --git a/.github/workflows/helm-chart-release.yml b/.github/workflows/helm-chart-release.yml new file mode 100644 index 0000000000..c0c5773bdb --- /dev/null +++ b/.github/workflows/helm-chart-release.yml @@ -0,0 +1,59 @@ +name: 'Helm: Chart Release' +# DISCLAIMER: This workflow is not maintained by the Prowler team. Refer to contrib/k8s/helm/prowler-app for the source code. + +on: + release: + types: + - 'published' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + CHART_PATH: contrib/k8s/helm/prowler-app + +jobs: + release-helm-chart: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + + - name: Set appVersion from release tag + run: | + RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME}" + echo "Setting appVersion to ${RELEASE_TAG}" + sed -i "s/^appVersion:.*/appVersion: \"${RELEASE_TAG}\"/" ${{ env.CHART_PATH }}/Chart.yaml + env: + GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} + + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${GITHUB_ACTOR} --password-stdin + + - name: Update chart dependencies + run: helm dependency update ${{ env.CHART_PATH }} + + - name: Package Helm chart + run: helm package ${{ env.CHART_PATH }} --destination .helm-packages + + - name: Push chart to GHCR + run: | + PACKAGE=$(ls .helm-packages/*.tgz) + helm push "$PACKAGE" oci://ghcr.io/${{ github.repository_owner }}/charts diff --git a/.github/workflows/issue-lock-on-close.yml b/.github/workflows/issue-lock-on-close.yml new file mode 100644 index 0000000000..27c7da0bb9 --- /dev/null +++ b/.github/workflows/issue-lock-on-close.yml @@ -0,0 +1,51 @@ +name: 'Tools: Lock Issue on Close' + +on: + issues: + types: + - closed + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + lock: + if: | + github.repository == 'prowler-cloud/prowler' && + github.event.issue.locked == false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + + - name: Comment and lock issue + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.payload.issue.number; + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: 'This issue is now locked as it has been closed. If you are still hitting a related problem, please open a new issue and link back to this one for context. Thanks!' + }); + } catch (error) { + core.warning(`Failed to post lock comment on issue #${issue_number}: ${error.message}`); + } + + const lockParams = { owner, repo, issue_number }; + if (context.payload.issue.state_reason === 'completed') { + lockParams.lock_reason = 'resolved'; + } + await github.rest.issues.lock(lockParams); diff --git a/.github/workflows/issue-triage.lock.yml b/.github/workflows/issue-triage.lock.yml index ae824478ca..fcc6f1e363 100644 --- a/.github/workflows/issue-triage.lock.yml +++ b/.github/workflows/issue-triage.lock.yml @@ -65,6 +65,11 @@ jobs: text: ${{ steps.compute-text.outputs.text }} title: ${{ steps.compute-text.outputs.title }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Setup Scripts uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 with: @@ -129,6 +134,11 @@ jobs: output_types: ${{ steps.collect_output.outputs.output_types }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Setup Scripts uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 with: @@ -859,6 +869,11 @@ jobs: tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} total_count: ${{ steps.missing_tool.outputs.total_count }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Setup Scripts uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 with: @@ -966,6 +981,11 @@ jobs: outputs: success: ${{ steps.parse_results.outputs.success }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Setup Scripts uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 with: @@ -1070,6 +1090,11 @@ jobs: outputs: activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Setup Scripts uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 with: @@ -1138,6 +1163,11 @@ jobs: process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Setup Scripts uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23 with: diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index ac181ce350..a42d6e1f73 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -24,6 +24,11 @@ jobs: pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Apply labels to PR uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: @@ -38,6 +43,11 @@ jobs: pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Check if author is org member id: check_membership env: @@ -65,7 +75,8 @@ jobs: "RosaRivasProwler" "StylusFrost" "toniblyx" - "vicferpoy" + "davidm4r" + "pfe-nazaries" ) echo "Checking if $AUTHOR is a member of prowler-cloud organization" diff --git a/.github/workflows/mcp-container-build-push.yml b/.github/workflows/mcp-container-build-push.yml index f5a677400b..3f59a455db 100644 --- a/.github/workflows/mcp-container-build-push.yml +++ b/.github/workflows/mcp-container-build-push.yml @@ -17,9 +17,6 @@ on: required: true type: string -permissions: - contents: read - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -42,7 +39,14 @@ jobs: timeout-minutes: 5 outputs: short-sha: ${{ steps.set-short-sha.outputs.short-sha }} + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + - name: Calculate short SHA id: set-short-sha run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT @@ -54,9 +58,16 @@ jobs: timeout-minutes: 5 outputs: message-ts: ${{ steps.slack-notification.outputs.ts }} + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -92,13 +103,27 @@ jobs: contents: read packages: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + registry-1.docker.io:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 + ghcr.io:443 + pkg-containers.githubusercontent.com:443 + files.pythonhosted.org:443 + pypi.org:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -109,7 +134,7 @@ jobs: - name: Build and push MCP container for ${{ matrix.arch }} id: container-push if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ${{ env.WORKING_DIRECTORY }} push: true @@ -132,17 +157,27 @@ jobs: needs: [setup, container-build-push] if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success' runs-on: ubuntu-latest + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + registry-1.docker.io:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 + github.com:443 + release-assets.githubusercontent.com:443 + - name: Login to DockerHub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Create and push manifests for push event if: github.event_name == 'push' run: | @@ -184,9 +219,16 @@ jobs: needs: [setup, notify-release-started, container-build-push, create-manifest] runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -227,6 +269,13 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + - name: Trigger MCP deployment uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index bcd89fce09..f8fbfca12f 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -27,15 +27,22 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: mcp_server/Dockerfile @@ -64,15 +71,32 @@ jobs: pull-requests: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + registry-1.docker.io:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 + ghcr.io:443 + pkg-containers.githubusercontent.com:443 + files.pythonhosted.org:443 + pypi.org:443 + api.github.com:443 + mirror.gcr.io:443 + check.trivy.dev:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for MCP changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: mcp_server/** files_ignore: | @@ -85,7 +109,7 @@ jobs: - name: Build MCP container for ${{ matrix.arch }} if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ${{ env.MCP_WORKING_DIR }} push: false diff --git a/.github/workflows/mcp-pypi-release.yml b/.github/workflows/mcp-pypi-release.yml index 31c22114ec..d6272034ec 100644 --- a/.github/workflows/mcp-pypi-release.yml +++ b/.github/workflows/mcp-pypi-release.yml @@ -26,6 +26,11 @@ jobs: major_version: ${{ steps.parse-version.outputs.major }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Parse and validate version id: parse-version run: | @@ -59,18 +64,23 @@ jobs: url: https://pypi.org/project/prowler-mcp/ steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 + uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 with: enable-cache: false - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} diff --git a/.github/workflows/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml index e70bbcd713..c729875843 100644 --- a/.github/workflows/pr-check-changelog.yml +++ b/.github/workflows/pr-check-changelog.yml @@ -28,8 +28,16 @@ jobs: MONITORED_FOLDERS: 'api ui prowler mcp_server' steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # zizmor: ignore[artipacked] @@ -37,7 +45,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | api/** diff --git a/.github/workflows/pr-check-compliance-mapping.yml b/.github/workflows/pr-check-compliance-mapping.yml new file mode 100644 index 0000000000..dcf61602ba --- /dev/null +++ b/.github/workflows/pr-check-compliance-mapping.yml @@ -0,0 +1,180 @@ +name: 'Tools: Check Compliance Mapping' + +on: + pull_request: + types: + - 'opened' + - 'synchronize' + - 'reopened' + - 'labeled' + - 'unlabeled' + branches: + - 'master' + - 'v5.*' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-compliance-mapping: + if: contains(github.event.pull_request.labels.*.name, 'no-compliance-check') == false + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: write + + steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + # zizmor: ignore[artipacked] + persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + with: + files: | + prowler/providers/**/services/**/*.metadata.json + prowler/compliance/**/*.json + + - name: Check if new checks are mapped in compliance + id: compliance-check + run: | + ADDED_METADATA="${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" + ALL_CHANGED="${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" + + # Filter only new metadata files (new checks) + new_checks="" + for f in $ADDED_METADATA; do + case "$f" in *.metadata.json) new_checks="$new_checks $f" ;; esac + done + + if [ -z "$(echo "$new_checks" | tr -d ' ')" ]; then + echo "No new checks detected." + echo "has_new_checks=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Collect compliance files changed in this PR + changed_compliance="" + for f in $ALL_CHANGED; do + case "$f" in prowler/compliance/*.json) changed_compliance="$changed_compliance $f" ;; esac + done + + UNMAPPED="" + MAPPED="" + + for metadata_file in $new_checks; do + check_dir=$(dirname "$metadata_file") + check_id=$(basename "$check_dir") + provider=$(echo "$metadata_file" | cut -d'/' -f3) + + # Read CheckID from the metadata JSON for accuracy + if [ -f "$metadata_file" ]; then + json_check_id=$(python3 -c "import json; print(json.load(open('$metadata_file')).get('CheckID', ''))" 2>/dev/null || echo "") + if [ -n "$json_check_id" ]; then + check_id="$json_check_id" + fi + fi + + # Search for the check ID in compliance files changed in this PR + found_in="" + for comp_file in $changed_compliance; do + if grep -q "\"${check_id}\"" "$comp_file" 2>/dev/null; then + found_in="${found_in}$(basename "$comp_file" .json), " + fi + done + + if [ -n "$found_in" ]; then + found_in=$(echo "$found_in" | sed 's/, $//') + MAPPED="${MAPPED}- \`${check_id}\` (\`${provider}\`): ${found_in}"$'\n' + else + UNMAPPED="${UNMAPPED}- \`${check_id}\` (\`${provider}\`)"$'\n' + fi + done + + echo "has_new_checks=true" >> "$GITHUB_OUTPUT" + + if [ -n "$UNMAPPED" ]; then + echo "has_unmapped=true" >> "$GITHUB_OUTPUT" + else + echo "has_unmapped=false" >> "$GITHUB_OUTPUT" + fi + + { + echo "unmapped<> "$GITHUB_OUTPUT" + + { + echo "mapped<> "$GITHUB_OUTPUT" + env: + STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }} + STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} + + - name: Manage compliance review label + if: steps.compliance-check.outputs.has_new_checks == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + HAS_UNMAPPED: ${{ steps.compliance-check.outputs.has_unmapped }} + run: | + LABEL_NAME="needs-compliance-review" + + if [ "$HAS_UNMAPPED" = "true" ]; then + echo "Adding compliance review label to PR #${PR_NUMBER}..." + gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME" --repo "${{ github.repository }}" || true + else + echo "Removing compliance review label from PR #${PR_NUMBER}..." + gh pr edit "$PR_NUMBER" --remove-label "$LABEL_NAME" --repo "${{ github.repository }}" || true + fi + + - name: Find existing compliance comment + if: steps.compliance-check.outputs.has_new_checks == 'true' && github.event.pull_request.head.repo.full_name == github.repository + id: find-comment + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: Create or update compliance comment + if: steps.compliance-check.outputs.has_new_checks == 'true' && github.event.pull_request.head.repo.full_name == github.repository + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + + ## Compliance Mapping Review + + This PR adds new checks. Please verify that they have been mapped to the relevant compliance framework requirements. + + ${{ steps.compliance-check.outputs.unmapped != '' && format('### New checks not mapped to any compliance framework in this PR + + {0} + + > Please review whether these checks should be added to compliance framework requirements in `prowler/compliance//`. Each compliance JSON has a `Checks` array inside each requirement — add the check ID there if it satisfies that requirement.', steps.compliance-check.outputs.unmapped) || '' }} + + ${{ steps.compliance-check.outputs.mapped != '' && format('### New checks already mapped in this PR + + {0}', steps.compliance-check.outputs.mapped) || '' }} + + Use the `no-compliance-check` label to skip this check. diff --git a/.github/workflows/pr-conflict-checker.yml b/.github/workflows/pr-conflict-checker.yml index 37dc5cc462..330a038fa0 100644 --- a/.github/workflows/pr-conflict-checker.yml +++ b/.github/workflows/pr-conflict-checker.yml @@ -25,8 +25,13 @@ jobs: issues: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout PR head - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -34,7 +39,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: '**' diff --git a/.github/workflows/pr-merged.yml b/.github/workflows/pr-merged.yml index 41856b0ede..62eca02e58 100644 --- a/.github/workflows/pr-merged.yml +++ b/.github/workflows/pr-merged.yml @@ -23,6 +23,13 @@ jobs: permissions: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + - name: Calculate short commit SHA id: vars run: | diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index be2b624baf..f0aee83174 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -26,15 +26,20 @@ jobs: contents: write pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} persist-credentials: false - name: Set up Python - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' @@ -345,7 +350,7 @@ jobs: - name: Create PR for API dependency update if: ${{ env.PATCH_VERSION == '0' }} - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} commit-message: 'chore(api): update prowler dependency to ${{ env.BRANCH_NAME }} for release ${{ env.PROWLER_VERSION }}' diff --git a/.github/workflows/sdk-bump-version.yml b/.github/workflows/sdk-bump-version.yml index 28f4b47ef5..99010c6af3 100644 --- a/.github/workflows/sdk-bump-version.yml +++ b/.github/workflows/sdk-bump-version.yml @@ -26,6 +26,11 @@ jobs: minor_version: ${{ steps.detect.outputs.minor_version }} patch_version: ${{ steps.detect.outputs.patch_version }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Detect release type and parse version id: detect run: | @@ -66,8 +71,13 @@ jobs: contents: read pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -96,7 +106,7 @@ jobs: git --no-pager diff - name: Create PR for next minor version to master - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -115,7 +125,7 @@ jobs: By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. - name: Checkout version branch - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }} persist-credentials: false @@ -148,7 +158,7 @@ jobs: git --no-pager diff - name: Create PR for first patch version to version branch - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -175,8 +185,13 @@ jobs: contents: read pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -211,7 +226,7 @@ jobs: git --no-pager diff - name: Create PR for next patch version to version branch - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} diff --git a/.github/workflows/sdk-check-duplicate-test-names.yml b/.github/workflows/sdk-check-duplicate-test-names.yml index 1f47c06ea9..ac8a01d117 100644 --- a/.github/workflows/sdk-check-duplicate-test-names.yml +++ b/.github/workflows/sdk-check-duplicate-test-names.yml @@ -19,8 +19,15 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false diff --git a/.github/workflows/sdk-code-quality.yml b/.github/workflows/sdk-code-quality.yml index f7066682a3..b854e9ddeb 100644 --- a/.github/workflows/sdk-code-quality.yml +++ b/.github/workflows/sdk-code-quality.yml @@ -24,21 +24,29 @@ jobs: strategy: matrix: python-version: - - '3.9' - '3.10' - '3.11' - '3.12' steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + pypi.org:443 + files.pythonhosted.org:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: ./** files_ignore: | @@ -67,7 +75,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} cache: 'poetry' diff --git a/.github/workflows/sdk-codeql.yml b/.github/workflows/sdk-codeql.yml index 138e943879..fa5056c2d6 100644 --- a/.github/workflows/sdk-codeql.yml +++ b/.github/workflows/sdk-codeql.yml @@ -48,18 +48,28 @@ jobs: - 'python' steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + release-assets.githubusercontent.com:443 + uploads.github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/sdk-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/sdk-container-build-push.yml b/.github/workflows/sdk-container-build-push.yml index b0c00da53e..4a0563b89f 100644 --- a/.github/workflows/sdk-container-build-push.yml +++ b/.github/workflows/sdk-container-build-push.yml @@ -23,9 +23,6 @@ on: required: true type: string -permissions: - contents: read - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -45,6 +42,7 @@ env: # Container registries PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud PROWLERCLOUD_DOCKERHUB_IMAGE: prowler + TONIBLYX_DOCKERHUB_REPOSITORY: toniblyx # AWS configuration (for ECR) AWS_REGION: us-east-1 @@ -59,14 +57,25 @@ jobs: prowler_version_major: ${{ steps.get-prowler-version.outputs.prowler_version_major }} latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }} stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }} + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + pypi.org:443 + files.pythonhosted.org:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} @@ -115,9 +124,16 @@ jobs: timeout-minutes: 5 outputs: message-ts: ${{ steps.slack-notification.outputs.ts }} + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -154,19 +170,40 @@ jobs: packages: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.ecr-public.us-east-1.amazonaws.com:443 + public.ecr.aws:443 + registry-1.docker.io:443 + production.cloudflare.docker.com:443 + auth.docker.io:443 + debian.map.fastlydns.net:80 + github.com:443 + release-assets.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + www.powershellgallery.com:443 + aka.ms:443 + cdn.powershellgallery.com:443 + _http._tcp.deb.debian.org:443 + powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to Public ECR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: public.ecr.aws username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} @@ -180,7 +217,7 @@ jobs: - name: Build and push SDK container for ${{ matrix.arch }} id: container-push if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . file: ${{ env.DOCKERFILE_PATH }} @@ -196,16 +233,32 @@ jobs: needs: [setup, container-build-push] if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success' runs-on: ubuntu-latest + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + registry-1.docker.io:443 + auth.docker.io:443 + public.ecr.aws:443 + production.cloudflare.docker.com:443 + github.com:443 + release-assets.githubusercontent.com:443 + api.ecr-public.us-east-1.amazonaws.com:443 + + - name: Login to DockerHub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to Public ECR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: public.ecr.aws username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} @@ -213,15 +266,11 @@ jobs: env: AWS_REGION: ${{ env.AWS_REGION }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Create and push manifests for push event if: github.event_name == 'push' run: | docker buildx imagetools create \ -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \ - -t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \ -t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \ ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \ ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64 @@ -232,12 +281,10 @@ jobs: if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' run: | docker buildx imagetools create \ - -t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \ - -t ${{ secrets.DOCKER_HUB_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \ - -t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \ - -t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \ -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \ -t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \ + -t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \ + -t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \ ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \ ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64 env: @@ -245,6 +292,39 @@ jobs: NEEDS_SETUP_OUTPUTS_STABLE_TAG: ${{ needs.setup.outputs.stable_tag }} NEEDS_SETUP_OUTPUTS_LATEST_TAG: ${{ needs.setup.outputs.latest_tag }} + # Push to toniblyx/prowler only for current version (latest/stable/release tags) + - name: Login to DockerHub (toniblyx) + if: needs.setup.outputs.latest_tag == 'latest' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }} + password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }} + + - name: Push manifests to toniblyx for push event + if: needs.setup.outputs.latest_tag == 'latest' && github.event_name == 'push' + run: | + docker buildx imagetools create \ + -t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:latest \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:latest + + - name: Push manifests to toniblyx for release event + if: needs.setup.outputs.latest_tag == 'latest' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') + run: | + docker buildx imagetools create \ + -t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \ + -t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable \ + ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable + env: + NEEDS_SETUP_OUTPUTS_PROWLER_VERSION: ${{ needs.setup.outputs.prowler_version }} + + # Re-login as prowlercloud for cleanup of intermediate tags + - name: Login to DockerHub (prowlercloud) + if: always() + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Install regctl if: always() uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main @@ -264,9 +344,16 @@ jobs: needs: [setup, notify-release-started, container-build-push, create-manifest] runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -307,6 +394,11 @@ jobs: contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Calculate short SHA id: short-sha run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index bbcc93c1b8..dacf160a86 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -26,15 +26,22 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: Dockerfile @@ -64,15 +71,38 @@ jobs: pull-requests: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + registry-1.docker.io:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 + api.github.com:443 + mirror.gcr.io:443 + check.trivy.dev:443 + debian.map.fastlydns.net:80 + release-assets.githubusercontent.com:443 + pypi.org:443 + files.pythonhosted.org:443 + www.powershellgallery.com:443 + aka.ms:443 + cdn.powershellgallery.com:443 + _http._tcp.deb.debian.org:443 + powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 + get.trivy.dev:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: ./** files_ignore: | @@ -101,7 +131,7 @@ jobs: - name: Build SDK container for ${{ matrix.arch }} if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: false diff --git a/.github/workflows/sdk-pypi-release.yml b/.github/workflows/sdk-pypi-release.yml index 369bf582fb..5173760205 100644 --- a/.github/workflows/sdk-pypi-release.yml +++ b/.github/workflows/sdk-pypi-release.yml @@ -25,6 +25,11 @@ jobs: major_version: ${{ steps.parse-version.outputs.major }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Parse and validate version id: parse-version run: | @@ -58,8 +63,13 @@ jobs: url: https://pypi.org/project/prowler/${{ needs.validate-release.outputs.prowler_version }}/ steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -67,7 +77,7 @@ jobs: run: pipx install poetry==2.1.1 - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} @@ -91,8 +101,13 @@ jobs: url: https://pypi.org/project/prowler-cloud/${{ needs.validate-release.outputs.prowler_version }}/ steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -100,7 +115,7 @@ jobs: run: pipx install poetry==2.1.1 - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} diff --git a/.github/workflows/sdk-refresh-aws-services-regions.yml b/.github/workflows/sdk-refresh-aws-services-regions.yml index 49cf139138..ce8d1c8292 100644 --- a/.github/workflows/sdk-refresh-aws-services-regions.yml +++ b/.github/workflows/sdk-refresh-aws-services-regions.yml @@ -24,14 +24,19 @@ jobs: contents: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: 'master' persist-credentials: false - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' @@ -40,7 +45,7 @@ jobs: run: pip install boto3 - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1 + uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 with: aws-region: ${{ env.AWS_REGION }} role-to-assume: ${{ secrets.DEV_IAM_ROLE_ARN }} @@ -51,7 +56,7 @@ jobs: - name: Create pull request id: create-pr - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>' diff --git a/.github/workflows/sdk-refresh-oci-regions.yml b/.github/workflows/sdk-refresh-oci-regions.yml index 5686491cb2..67f87076b7 100644 --- a/.github/workflows/sdk-refresh-oci-regions.yml +++ b/.github/workflows/sdk-refresh-oci-regions.yml @@ -22,14 +22,19 @@ jobs: contents: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: 'master' persist-credentials: false - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' @@ -48,7 +53,7 @@ jobs: - name: Create pull request id: create-pr - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>' @@ -72,12 +77,13 @@ jobs: This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`). - Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged + - DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged - Region display names are mapped from Oracle's official documentation ### Checklist - [x] This is an automated update from OCI official sources - - [x] Government regions (us-langley-1, us-luke-1) preserved + - [x] Government regions (us-langley-1, us-luke-1) and DOD regions (us-gov-ashburn-1, us-gov-phoenix-1, us-gov-chicago-1) are preserved - [x] No manual review of region data required ### License diff --git a/.github/workflows/sdk-security.yml b/.github/workflows/sdk-security.yml index cabdeb50ac..2229568b3f 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -23,15 +23,28 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + pypi.org:443 + files.pythonhosted.org:443 + github.com:443 + auth.safetycli.com:443 + pyup.io:443 + data.safetycli.com:443 + api.github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: ./** @@ -62,7 +75,7 @@ jobs: - name: Set up Python 3.12 if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' cache: 'poetry' diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 7c5f5c7851..d0da08cc55 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -24,21 +24,50 @@ jobs: strategy: matrix: python-version: - - '3.9' - '3.10' - '3.11' - '3.12' steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + pypi.org:443 + files.pythonhosted.org:443 + api.github.com:443 + release-assets.githubusercontent.com:443 + *.amazonaws.com:443 + *.googleapis.com:443 + schema.ocsf.io:443 + registry-1.docker.io:443 + production.cloudflare.docker.com:443 + powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443 + o26192.ingest.us.sentry.io:443 + management.azure.com:443 + login.microsoftonline.com:443 + keybase.io:443 + ingest.codecov.io:443 + graph.microsoft.com:443 + dc.services.visualstudio.com:443 + cloud.mongodb.com:443 + cli.codecov.io:443 + auth.docker.io:443 + api.vercel.com:443 + api.atlassian.com:443 + aka.ms:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: ./** files_ignore: | @@ -67,7 +96,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} cache: 'poetry' @@ -80,7 +109,7 @@ jobs: - name: Check if AWS files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-aws - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/aws/** @@ -210,7 +239,7 @@ jobs: - name: Check if Azure files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-azure - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/azure/** @@ -234,7 +263,7 @@ jobs: - name: Check if GCP files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-gcp - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/gcp/** @@ -258,7 +287,7 @@ jobs: - name: Check if Kubernetes files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-kubernetes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/kubernetes/** @@ -282,7 +311,7 @@ jobs: - name: Check if GitHub files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-github - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/github/** @@ -306,7 +335,7 @@ jobs: - name: Check if NHN files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-nhn - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/nhn/** @@ -330,7 +359,7 @@ jobs: - name: Check if M365 files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-m365 - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/m365/** @@ -354,7 +383,7 @@ jobs: - name: Check if IaC files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-iac - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/iac/** @@ -378,7 +407,7 @@ jobs: - name: Check if MongoDB Atlas files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-mongodbatlas - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/mongodbatlas/** @@ -402,7 +431,7 @@ jobs: - name: Check if OCI files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-oraclecloud - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/oraclecloud/** @@ -426,7 +455,7 @@ jobs: - name: Check if OpenStack files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-openstack - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/openstack/** @@ -450,7 +479,7 @@ jobs: - name: Check if Google Workspace files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-googleworkspace - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/**/googleworkspace/** @@ -470,11 +499,35 @@ jobs: flags: prowler-py${{ matrix.python-version }}-googleworkspace files: ./googleworkspace_coverage.xml + # Vercel Provider + - name: Check if Vercel files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-vercel + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 + with: + files: | + ./prowler/**/vercel/** + ./tests/**/vercel/** + ./poetry.lock + + - name: Run Vercel tests + if: steps.changed-vercel.outputs.any_changed == 'true' + run: poetry run pytest -n auto --cov=./prowler/providers/vercel --cov-report=xml:vercel_coverage.xml tests/providers/vercel + + - name: Upload Vercel coverage to Codecov + if: steps.changed-vercel.outputs.any_changed == 'true' + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + flags: prowler-py${{ matrix.python-version }}-vercel + files: ./vercel_coverage.xml + # Lib - name: Check if Lib files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-lib - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/lib/** @@ -498,7 +551,7 @@ jobs: - name: Check if Config files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-config - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ./prowler/config/** diff --git a/.github/workflows/test-impact-analysis.yml b/.github/workflows/test-impact-analysis.yml index 8fc3224e5f..f03f99d0fc 100644 --- a/.github/workflows/test-impact-analysis.yml +++ b/.github/workflows/test-impact-analysis.yml @@ -45,20 +45,31 @@ jobs: has-sdk-tests: ${{ steps.set-flags.outputs.has-sdk-tests }} has-api-tests: ${{ steps.set-flags.outputs.has-api-tests }} has-ui-e2e: ${{ steps.set-flags.outputs.has-ui-e2e }} + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + pypi.org:443 + files.pythonhosted.org:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Get changed files id: changed-files - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 - name: Setup Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.12' diff --git a/.github/workflows/ui-bump-version.yml b/.github/workflows/ui-bump-version.yml index f91fb5db9b..9566c53006 100644 --- a/.github/workflows/ui-bump-version.yml +++ b/.github/workflows/ui-bump-version.yml @@ -26,6 +26,11 @@ jobs: minor_version: ${{ steps.detect.outputs.minor_version }} patch_version: ${{ steps.detect.outputs.patch_version }} steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Detect release type and parse version id: detect run: | @@ -66,8 +71,13 @@ jobs: contents: read pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -89,13 +99,13 @@ jobs: run: | set -e - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env echo "Files modified:" git --no-pager diff - name: Create PR for next minor version to master - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -117,7 +127,7 @@ jobs: By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license. - name: Checkout version branch - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }} persist-credentials: false @@ -143,13 +153,13 @@ jobs: run: | set -e - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env echo "Files modified:" git --no-pager diff - name: Create PR for first patch version to version branch - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} @@ -179,8 +189,13 @@ jobs: contents: read pull-requests: write steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -208,13 +223,13 @@ jobs: run: | set -e - sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env + sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env echo "Files modified:" git --no-pager diff - name: Create PR for next patch version to version branch - uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} diff --git a/.github/workflows/ui-codeql.yml b/.github/workflows/ui-codeql.yml index fafa430baf..c4ab182e92 100644 --- a/.github/workflows/ui-codeql.yml +++ b/.github/workflows/ui-codeql.yml @@ -44,18 +44,28 @@ jobs: - 'javascript-typescript' steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + release-assets.githubusercontent.com:443 + uploads.github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/ui-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml index 81d4aa69cb..c8886a5ae9 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -17,9 +17,6 @@ on: required: true type: string -permissions: - contents: read - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -45,7 +42,14 @@ jobs: timeout-minutes: 5 outputs: short-sha: ${{ steps.set-short-sha.outputs.short-sha }} + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Calculate short SHA id: set-short-sha run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT @@ -57,9 +61,16 @@ jobs: timeout-minutes: 5 outputs: message-ts: ${{ steps.slack-notification.outputs.ts }} + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -96,13 +107,27 @@ jobs: packages: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + registry-1.docker.io:443 + production.cloudflare.docker.com:443 + auth.docker.io:443 + registry.npmjs.org:443 + dl-cdn.alpinelinux.org:443 + fonts.googleapis.com:443 + fonts.gstatic.com:443 + github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -113,7 +138,7 @@ jobs: - name: Build and push UI container for ${{ matrix.arch }} id: container-push if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ${{ env.WORKING_DIRECTORY }} build-args: | @@ -131,17 +156,27 @@ jobs: needs: [setup, container-build-push] if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success' runs-on: ubuntu-latest + permissions: + contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + release-assets.githubusercontent.com:443 + registry-1.docker.io:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 + - name: Login to DockerHub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Create and push manifests for push event if: github.event_name == 'push' run: | @@ -183,9 +218,16 @@ jobs: needs: [setup, notify-release-started, container-build-push, create-manifest] runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -226,6 +268,13 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + - name: Trigger UI deployment uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: diff --git a/.github/workflows/ui-container-checks.yml b/.github/workflows/ui-container-checks.yml index d986c14b1b..10a910ace4 100644 --- a/.github/workflows/ui-container-checks.yml +++ b/.github/workflows/ui-container-checks.yml @@ -27,15 +27,22 @@ jobs: contents: read steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: ui/Dockerfile @@ -65,15 +72,33 @@ jobs: pull-requests: write steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + registry-1.docker.io:443 + auth.docker.io:443 + production.cloudflare.docker.com:443 + registry.npmjs.org:443 + dl-cdn.alpinelinux.org:443 + fonts.googleapis.com:443 + fonts.gstatic.com:443 + api.github.com:443 + mirror.gcr.io:443 + check.trivy.dev:443 + get.trivy.dev:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for UI changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: ui/** files_ignore: | @@ -87,7 +112,7 @@ jobs: - name: Build UI container for ${{ matrix.arch }} if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: ${{ env.UI_WORKING_DIR }} target: prod diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 0e8a146b26..53533474fa 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -15,13 +15,12 @@ on: - 'ui/**' - 'api/**' # API changes can affect UI E2E -permissions: - contents: read - jobs: # First, analyze which tests need to run impact-analysis: if: github.repository == 'prowler-cloud/prowler' + permissions: + contents: read uses: ./.github/workflows/test-impact-analysis.yml # Run E2E tests based on impact analysis @@ -75,10 +74,17 @@ jobs: # Pass E2E paths from impact analysis E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }} RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }} + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -152,21 +158,21 @@ jobs: ' - name: Setup Node.js - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '24.13.0' - name: Setup pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 with: - version: 10 + package_json_file: ui/package.json run_install: false - name: Get pnpm store directory run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm and Next.js cache - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | ${{ env.STORE_PATH }} @@ -186,7 +192,7 @@ jobs: run: pnpm run build - name: Cache Playwright browsers - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 id: playwright-cache with: path: ~/.cache/ms-playwright @@ -214,11 +220,40 @@ jobs: TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u) # Drop auth setup helpers (not runnable test suites) TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/') + # Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**), + # expand to specific subdirs to avoid Playwright discovering setup files + if echo "$TEST_PATHS" | grep -qx 'tests/'; then + echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..." + SPECIFIC_DIRS="" + for dir in tests/*/; do + [[ "$dir" == "tests/setups/" ]] && continue + SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n' + done + # Replace "tests/" with specific dirs, keep other paths + TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/') + TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}" + TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u) + fi if [[ -z "$TEST_PATHS" ]]; then echo "No runnable E2E test paths after filtering setups" exit 0 fi - TEST_PATHS=$(echo "$TEST_PATHS" | tr '\n' ' ') + # Filter out directories that don't contain any test files + VALID_PATHS="" + while IFS= read -r p; do + [[ -z "$p" ]] && continue + if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then + VALID_PATHS="${VALID_PATHS}${p}"$'\n' + else + echo "Skipping empty test directory: $p" + fi + done <<< "$TEST_PATHS" + VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true) + if [[ -z "$VALID_PATHS" ]]; then + echo "No test files found in any resolved paths — skipping E2E" + exit 0 + fi + TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ') echo "Resolved test paths: $TEST_PATHS" pnpm exec playwright test $TEST_PATHS fi @@ -244,7 +279,14 @@ jobs: needs.impact-analysis.outputs.has-ui-e2e != 'true' && needs.impact-analysis.outputs.run-all != 'true' runs-on: ubuntu-latest + permissions: + contents: read steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: No E2E tests needed run: | echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 1476d38c19..57cb149523 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -29,15 +29,27 @@ jobs: working-directory: ./ui steps: + - name: Harden Runner + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + registry.npmjs.org:443 + fonts.googleapis.com:443 + fonts.gstatic.com:443 + api.github.com:443 + release-assets.githubusercontent.com:443 + - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: # zizmor: ignore[artipacked] persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch - name: Check for UI changes id: check-changes - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ui/** @@ -50,7 +62,7 @@ jobs: - name: Get changed source files for targeted tests id: changed-source if: steps.check-changes.outputs.any_changed == 'true' - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ui/**/*.ts @@ -66,7 +78,7 @@ jobs: - name: Check for critical path changes (run all tests) id: critical-changes if: steps.check-changes.outputs.any_changed == 'true' - uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1 + uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4 with: files: | ui/lib/** @@ -78,15 +90,15 @@ jobs: - name: Setup Node.js ${{ env.NODE_VERSION }} if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm if: steps.check-changes.outputs.any_changed == 'true' - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 with: - version: 10 + package_json_file: ui/package.json run_install: false - name: Get pnpm store directory @@ -96,7 +108,7 @@ jobs: - name: Setup pnpm and Next.js cache if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: | ${{ env.STORE_PATH }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000000..931ef1b94c --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,25 @@ +rules: + secrets-outside-env: + ignore: + - api-bump-version.yml + - api-container-build-push.yml + - api-tests.yml + - backport.yml + - docs-bump-version.yml + - issue-triage.lock.yml + - mcp-container-build-push.yml + - pr-merged.yml + - prepare-release.yml + - sdk-bump-version.yml + - sdk-container-build-push.yml + - sdk-refresh-aws-services-regions.yml + - sdk-refresh-oci-regions.yml + - sdk-tests.yml + - ui-bump-version.yml + - ui-container-build-push.yml + - ui-e2e-tests-v2.yml + superfluous-actions: + ignore: + - pr-check-changelog.yml + - pr-conflict-checker.yml + - prepare-release.yml diff --git a/.gitignore b/.gitignore index 4dfb137b89..8686f4f143 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ htmlcov/ **/mcp-config.json **/mcpServers.json .mcp/ +.mcp.json # AI Coding Assistants - Cursor .cursorignore @@ -163,3 +164,6 @@ GEMINI.md .codex/skills .github/skills .gemini/skills + +# Claude Code +.claude/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb65669765..a45f436284 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,6 +22,13 @@ repos: args: [--autofix] files: pyproject.toml + ## GITHUB ACTIONS + - repo: https://github.com/zizmorcore/zizmor-pre-commit + rev: v1.6.0 + hooks: + - id: zizmor + files: ^\.github/ + ## BASH - repo: https://github.com/koalaman/shellcheck-precommit rev: v0.10.0 @@ -120,7 +127,8 @@ repos: description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" # TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X # TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0 - entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027' + # TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0` + entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,86217' language: system - id: vulture diff --git a/AGENTS.md b/AGENTS.md index 600d353ef9..5302e38f52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,8 @@ Use these skills for detailed patterns on-demand: | `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) | | `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) | | `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) | +| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) | +| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) | | `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) | | `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) | | `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) | @@ -85,15 +87,15 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Fixing bug | `tdd` | | General Prowler development questions | `prowler` | | Implementing JSON:API endpoints | `django-drf` | -| Importing Copilot Custom Agents into workflows | `gh-aw` | | Implementing feature | `tdd` | +| Importing Copilot Custom Agents into workflows | `gh-aw` | | Inspect PR CI checks and gates (.github/workflows/*) | `prowler-ci` | | Inspect PR CI workflows (.github/workflows/*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` | | Mapping checks to compliance controls | `prowler-compliance` | | Mocking AWS with moto in tests | `prowler-test-sdk` | | Modifying API responses | `jsonapi` | -| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` | | Modifying component | `tdd` | +| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` | | Refactoring code | `tdd` | | Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` | | Review PR requirements: template, title conventions, changelog gate | `prowler-pr` | @@ -138,7 +140,7 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure, | Component | Location | Tech Stack | |-----------|----------|------------| -| SDK | `prowler/` | Python 3.9+, Poetry | +| SDK | `prowler/` | Python 3.10+, Poetry | | API | `api/` | Django 5.1, DRF, Celery | | UI | `ui/` | Next.js 15, React 19, Tailwind 4 | | MCP Server | `mcp_server/` | FastMCP, Python 3.12+ | diff --git a/Dockerfile b/Dockerfile index 60d72ad469..0dbe63ece0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.11-slim-bookworm AS build +FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS build LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" diff --git a/README.md b/README.md index 3d1589f6cd..90003e7055 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

- Prowler is the Open Cloud Security platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size. + Prowler is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.

Secure ANY cloud at AI Speed at prowler.com @@ -41,7 +41,7 @@ # Description -**Prowler** is the world’s most widely used _open-source cloud security platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size. +**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size. Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including: @@ -109,14 +109,17 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | GCP | 100 | 13 | 15 | 11 | Official | UI, API, CLI | | Kubernetes | 83 | 7 | 7 | 9 | Official | UI, API, CLI | | GitHub | 21 | 2 | 1 | 2 | Official | UI, API, CLI | -| M365 | 75 | 7 | 4 | 4 | Official | UI, API, CLI | -| OCI | 51 | 13 | 3 | 12 | Official | UI, API, CLI | +| M365 | 89 | 9 | 4 | 5 | Official | UI, API, CLI | +| OCI | 48 | 13 | 3 | 10 | Official | UI, API, CLI | | Alibaba Cloud | 61 | 9 | 3 | 9 | Official | UI, API, CLI | -| Cloudflare | 29 | 2 | 0 | 5 | Official | CLI, API | +| Cloudflare | 29 | 2 | 0 | 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 | 3 | Official | UI, API, CLI | +| MongoDB Atlas | 10 | 3 | 0 | 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 | -| OpenStack | 1 | 1 | 0 | 2 | Official | CLI | +| Image | N/A | N/A | N/A | N/A | Official | CLI, API | +| Google Workspace | 1 | 1 | 0 | 1 | Official | CLI | +| OpenStack | 27 | 4 | 0 | 8 | Official | UI, API, CLI | +| Vercel | 30 | 6 | 0 | 5 | Official | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | > [!Note] @@ -237,9 +240,24 @@ pnpm start > Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started. +**Pre-commit Hooks Setup** + +Some pre-commit hooks require tools installed on your system: + +1. **Install [TruffleHog](https://github.com/trufflesecurity/trufflehog#install)** (secret scanning) — see the [official installation options](https://github.com/trufflesecurity/trufflehog#install). + +2. **Install [Safety](https://github.com/pyupio/safety)** (dependency vulnerability checking): + + ```console + # Requires a Python environment (e.g. via pyenv) + pip install safety + ``` + +3. **Install [Hadolint](https://github.com/hadolint/hadolint#install)** (Dockerfile linting) — see the [official installation options](https://github.com/hadolint/hadolint#install). + ## Prowler CLI ### Pip package -Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >3.9.1, <3.13: +Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >=3.10, <3.13: ```console pip install prowler @@ -271,7 +289,7 @@ The container images are available here: ### From GitHub -Python >3.9.1, <3.13 is required with pip and Poetry: +Python >=3.10, <3.13 is required with pip and Poetry: ``` console git clone https://github.com/prowler-cloud/prowler diff --git a/api/AGENTS.md b/api/AGENTS.md index e1738b4a26..2bdee1b28c 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -4,6 +4,8 @@ > - [`prowler-api`](../skills/prowler-api/SKILL.md) - Models, Serializers, Views, RLS patterns > - [`prowler-test-api`](../skills/prowler-test-api/SKILL.md) - Testing patterns (pytest-django) > - [`prowler-attack-paths-query`](../skills/prowler-attack-paths-query/SKILL.md) - Attack Paths openCypher queries +> - [`django-migration-psql`](../skills/django-migration-psql/SKILL.md) - Migration best practices for PostgreSQL +> - [`postgresql-indexing`](../skills/postgresql-indexing/SKILL.md) - PostgreSQL indexing, EXPLAIN, monitoring, maintenance > - [`django-drf`](../skills/django-drf/SKILL.md) - Generic DRF patterns > - [`jsonapi`](../skills/jsonapi/SKILL.md) - Strict JSON:API v1.1 spec compliance > - [`pytest`](../skills/pytest/SKILL.md) - Generic pytest patterns @@ -16,14 +18,20 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: |--------|-------| | Add changelog entry for a PR or feature | `prowler-changelog` | | Adding DRF pagination or permissions | `django-drf` | +| Adding indexes or constraints to database tables | `django-migration-psql` | | Adding privilege escalation detection queries | `prowler-attack-paths-query` | +| Analyzing query performance with EXPLAIN | `postgresql-indexing` | | Committing changes | `prowler-commit` | | Create PR that requires changelog entry | `prowler-changelog` | | Creating API endpoints | `jsonapi` | | Creating Attack Paths queries | `prowler-attack-paths-query` | | Creating ViewSets, serializers, or filters in api/ | `django-drf` | | Creating a git commit | `prowler-commit` | +| Creating or modifying PostgreSQL indexes | `postgresql-indexing` | +| Creating or reviewing Django migrations | `django-migration-psql` | | Creating/modifying models, views, serializers | `prowler-api` | +| Debugging slow queries or missing indexes | `postgresql-indexing` | +| Dropping or reindexing PostgreSQL indexes | `postgresql-indexing` | | Fixing bug | `tdd` | | Implementing JSON:API endpoints | `django-drf` | | Implementing feature | `tdd` | @@ -32,12 +40,14 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Refactoring code | `tdd` | | Review changelog format and conventions | `prowler-changelog` | | Reviewing JSON:API compliance | `jsonapi` | +| Running makemigrations or pgmakemigrations | `django-migration-psql` | | Testing RLS tenant isolation | `prowler-test-api` | | Update CHANGELOG.md in any component | `prowler-changelog` | | Updating existing Attack Paths queries | `prowler-attack-paths-query` | | Working on task | `tdd` | | Writing Prowler API tests | `prowler-test-api` | | Writing Python tests with pytest | `pytest` | +| Writing data backfill or data migration | `django-migration-psql` | --- diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 439f07957c..49d856302e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,6 +2,106 @@ All notable changes to the **Prowler API** are documented in this file. +## [1.24.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469) +- Filter RBAC role lookup by `tenant_id` to prevent cross-tenant privilege leak [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) +- `VALKEY_SCHEME`, `VALKEY_USERNAME`, and `VALKEY_PASSWORD` environment variables to configure Celery broker TLS/auth connection details for Valkey/ElastiCache [(#10420)](https://github.com/prowler-cloud/prowler/pull/10420) +- `Vercel` provider support [(#10190)](https://github.com/prowler-cloud/prowler/pull/10190) + +### 🔄 Changed + +- Attack Paths: Periodic cleanup of stale scans with dead-worker detection via Celery inspect, marking orphaned `EXECUTING` scans as `FAILED` and recovering `graph_data_ready` [(#10387)](https://github.com/prowler-cloud/prowler/pull/10387) +- Attack Paths: Replace `_provider_id` property with `_Provider_{uuid}` label for provider isolation, add regex-based label injection for custom queries [(#10402)](https://github.com/prowler-cloud/prowler/pull/10402) + +### 🐞 Fixed + +- Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428) +- Populate compliance data inside `check_metadata` for findings, which was always returned as `null` [(#10449)](https://github.com/prowler-cloud/prowler/pull/10449) +- 403 error for admin users listing tenants due to roles query not using the admin database connection [(#10460)](https://github.com/prowler-cloud/prowler/pull/10460) +- Filter transient Neo4j defunct connection logs in Sentry `before_send` to suppress false-positive alerts handled by `RetryableSession` retries [(#10452)](https://github.com/prowler-cloud/prowler/pull/10452) +- `MANAGE_ACCOUNT` permission no longer required for listing and creating tenants [(#10468)](https://github.com/prowler-cloud/prowler/pull/10468) +- Finding groups muted filter, counters, metadata extraction and mute reaggregation [(#10477)](https://github.com/prowler-cloud/prowler/pull/10477) +- Finding groups `check_title__icontains` resolution, `name__icontains` resource filter and `resource_group` field in `/resources` response [(#10486)](https://github.com/prowler-cloud/prowler/pull/10486) +- Membership `post_delete` signal using raw FK ids to avoid `DoesNotExist` during cascade deletions [(#10497)](https://github.com/prowler-cloud/prowler/pull/10497) +- Finding group resources endpoints returning false 404 when filters match no results, and `sort` parameter being ignored [(#10510)](https://github.com/prowler-cloud/prowler/pull/10510) +- Jira integration failing with `JiraInvalidIssueTypeError` on non-English Jira instances due to hardcoded `"Task"` issue type; now dynamically fetches available issue types per project [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) +- Attack Paths: Remove `clear_cache` call from read-only query endpoints; cache clearing belongs to the scan/ingestion flow, not API queries [(#10586)](https://github.com/prowler-cloud/prowler/pull/10586) + +### 🔐 Security + +- Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469) +- `authlib` bumped from 1.6.6 to 1.6.9 to fix CVE-2026-28802 (JWT `alg: none` validation bypass) [(#10579)](https://github.com/prowler-cloud/prowler/pull/10579) + +--- + +## [1.23.0] (Prowler v5.22.0) + +### 🚀 Added + +- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377) + +### 🐞 Fixed + +- Finding groups latest endpoint now aggregates the latest snapshot per provider before check-level totals, keeping impacted resources aligned across providers [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419) +- Mute rule creation now triggers finding-group summary re-aggregation after historical muting, keeping stats in sync after mute operations [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419) +- Attack Paths: Deduplicate nodes before ProwlerFinding lookup in Attack Paths Cypher queries, reducing execution time [(#10424)](https://github.com/prowler-cloud/prowler/pull/10424) + +### 🔐 Security + +- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165) +- Bump `flask` to 3.1.3 (CVE-2026-27205) and `werkzeug` to 3.1.6 (CVE-2026-27199) [(#10430)](https://github.com/prowler-cloud/prowler/pull/10430) + +--- + +## [1.22.1] (Prowler v5.21.1) + +### 🐞 Fixed + +- Threat score aggregation query to eliminate unnecessary JOINs and `COUNT(DISTINCT)` overhead [(#10394)](https://github.com/prowler-cloud/prowler/pull/10394) + +--- + +## [1.22.0] (Prowler v5.21.0) + +### 🚀 Added + +- `CORS_ALLOWED_ORIGINS` configurable via environment variable [(#10355)](https://github.com/prowler-cloud/prowler/pull/10355) +- Attack Paths: Tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308) + +### 🔄 Changed + +- Attack Paths: Complete migration to private graph labels and properties, removing deprecated dual-write support [(#10268)](https://github.com/prowler-cloud/prowler/pull/10268) +- Attack Paths: Reduce sync and findings memory usage with smaller batches, cursor iteration, and sequential sessions [(#10359)](https://github.com/prowler-cloud/prowler/pull/10359) + +### 🐞 Fixed + +- Attack Paths: Recover `graph_data_ready` flag when scan fails during graph swap, preventing query endpoints from staying blocked until the next successful scan [(#10354)](https://github.com/prowler-cloud/prowler/pull/10354) + +### 🔐 Security + +- Use `psycopg2.sql` to safely compose DDL in `PostgresEnumMigration`, preventing SQL injection via f-string interpolation [(#10166)](https://github.com/prowler-cloud/prowler/pull/10166) +- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165) + +--- + +## [1.21.0] (Prowler v5.20.0) + +### 🔄 Changed + +- Attack Paths: Migrate network exposure queries from APOC to standard openCypher for Neo4j and Neptune compatibility [(#10266)](https://github.com/prowler-cloud/prowler/pull/10266) +- `POST /api/v1/providers` returns `409 Conflict` if already exists [(#10293)](https://github.com/prowler-cloud/prowler/pull/10293) + +### 🐞 Fixed + +- Attack Paths: Security hardening for custom query endpoint (Cypher blocklist, input validation, rate limiting, Helm lockdown) [(#10238)](https://github.com/prowler-cloud/prowler/pull/10238) +- Attack Paths: Missing logging for query execution and exception details in scan error handling [(#10269)](https://github.com/prowler-cloud/prowler/pull/10269) +- Attack Paths: Upgrade Cartography from 0.129.0 to 0.132.0, fixing `exposed_internet` not set on ELB/ELBv2 nodes [(#10272)](https://github.com/prowler-cloud/prowler/pull/10272) + +--- + ## [1.20.0] (Prowler v5.19.0) ### 🚀 Added @@ -11,6 +111,7 @@ All notable changes to the **Prowler API** are documented in this file. - PDF report for the CSA CCM compliance framework [(#10088)](https://github.com/prowler-cloud/prowler/pull/10088) - `image` provider support for container image scanning [(#10128)](https://github.com/prowler-cloud/prowler/pull/10128) - Attack Paths: Custom query and Cartography schema endpoints (temporarily blocked) [(#10149)](https://github.com/prowler-cloud/prowler/pull/10149) +- `googleworkspace` provider support [(#10247)](https://github.com/prowler-cloud/prowler/pull/10247) ### 🔄 Changed @@ -29,6 +130,7 @@ All notable changes to the **Prowler API** are documented in this file. ### 🐞 Fixed +- PDF compliance reports consistency with UI: exclude resourceless findings and fix ENS MANUAL status handling [(#10270)](https://github.com/prowler-cloud/prowler/pull/10270) - Attack Paths: Orphaned temporary Neo4j databases are now cleaned up on scan failure and provider deletion [(#10101)](https://github.com/prowler-cloud/prowler/pull/10101) - Attack Paths: scan no longer raises `DatabaseError` when provider is deleted mid-scan [(#10116)](https://github.com/prowler-cloud/prowler/pull/10116) - Tenant compliance summaries recalculated after provider deletion [(#10172)](https://github.com/prowler-cloud/prowler/pull/10172) diff --git a/api/Dockerfile b/api/Dockerfile index 508bcae60e..ffa12c6f88 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.10-slim-bookworm AS build +FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db AS build LABEL maintainer="https://github.com/prowler-cloud/api" @@ -24,13 +24,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3-dev \ && rm -rf /var/lib/apt/lists/* -# Cartography depends on `dockerfile` which has no pre-built arm64 wheel and requires Go to compile -# hadolint ignore=DL3008 -RUN if [ "$(uname -m)" = "aarch64" ]; then \ - apt-get update && apt-get install -y --no-install-recommends golang-go \ - && rm -rf /var/lib/apt/lists/* ; \ - fi - # Install PowerShell RUN ARCH=$(uname -m) && \ if [ "$ARCH" = "x86_64" ]; then \ diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index eea024a4a2..a980595af2 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -30,9 +30,28 @@ start_prod_server() { poetry run gunicorn -c config/guniconf.py config.wsgi:application } +resolve_worker_hostname() { + TASK_ID="" + + if [ -n "$ECS_CONTAINER_METADATA_URI_V4" ]; then + TASK_ID=$(wget -qO- --timeout=2 "${ECS_CONTAINER_METADATA_URI_V4}/task" | \ + python3 -c "import sys,json; print(json.load(sys.stdin)['TaskARN'].split('/')[-1])" 2>/dev/null) + fi + + if [ -z "$TASK_ID" ]; then + TASK_ID=$(python3 -c "import uuid; print(uuid.uuid4().hex)") + fi + + echo "${TASK_ID}@$(hostname)" +} + start_worker() { echo "Starting the worker..." - poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans -E --max-tasks-per-child 1 + poetry run python -m celery -A config.celery worker \ + -n "$(resolve_worker_hostname)" \ + -l "${DJANGO_LOGGING_LEVEL:-info}" \ + -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \ + -E --max-tasks-per-child 1 } start_worker_beat() { diff --git a/api/poetry.lock b/api/poetry.lock index 6aba9045da..95f7cce24e 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "about-time" @@ -943,14 +943,14 @@ files = [ [[package]] name = "authlib" -version = "1.6.6" +version = "1.6.9" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd"}, - {file = "authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e"}, + {file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"}, + {file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"}, ] [package.dependencies] @@ -1822,14 +1822,14 @@ crt = ["awscrt (==0.27.6)"] [[package]] name = "cartography" -version = "0.129.0" +version = "0.132.0" description = "Explore assets and their relationships across your technical infrastructure." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "cartography-0.129.0-py3-none-any.whl", hash = "sha256:d42c840369be9e4d0ac4d024074e3732416e40bab3d9a3023b6a247918daed4c"}, - {file = "cartography-0.129.0.tar.gz", hash = "sha256:cb47d603e652554a4cbcc1a868c96014eb02b3d5cc1affea0428b2ed7fa61699"}, + {file = "cartography-0.132.0-py3-none-any.whl", hash = "sha256:c070aa51d0ab4479cb043cae70b35e7df49f2fb5f1fa95ccf10000bbeb952262"}, + {file = "cartography-0.132.0.tar.gz", hash = "sha256:7c6332bc57fd2629d7b83aee7bd95a7b2edb0d51ef746efa0461399e0b66625c"}, ] [package.dependencies] @@ -1864,8 +1864,8 @@ boto3 = ">=1.15.1" botocore = ">=1.18.1" cloudflare = ">=4.1.0,<5.0.0" crowdstrike-falconpy = ">=0.5.1" +cryptography = "*" dnspython = ">=1.15.0" -dockerfile = ">=3.0.0" duo-client = "*" google-api-python-client = ">=1.7.8" google-auth = ">=2.37.0" @@ -2469,22 +2469,18 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cron-descriptor" -version = "2.0.6" +version = "1.4.5" description = "A Python library that converts cron expressions into human readable strings." optional = false -python-versions = ">=3.9" +python-versions = "*" groups = ["main"] files = [ - {file = "cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6"}, - {file = "cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157"}, + {file = "cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013"}, + {file = "cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca"}, ] -[package.dependencies] -typing_extensions = "*" - [package.extras] -dev = ["mypy", "polib", "ruff"] -test = ["pytest"] +dev = ["polib"] [[package]] name = "crowdstrike-falconpy" @@ -2700,23 +2696,17 @@ files = [ ] [[package]] -name = "deprecated" -version = "1.3.1" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" groups = ["main"] files = [ - {file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"}, - {file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"}, + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] -[package.dependencies] -wrapt = ">=1.10,<3" - -[package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] - [[package]] name = "detect-secrets" version = "1.5.0" @@ -2807,14 +2797,14 @@ bcrypt = ["bcrypt"] [[package]] name = "django-allauth" -version = "65.14.0" +version = "65.15.0" description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "django_allauth-65.14.0-py3-none-any.whl", hash = "sha256:448f5f7877f95fcbe1657256510fe7822d7871f202521a29e23ef937f3325a97"}, - {file = "django_allauth-65.14.0.tar.gz", hash = "sha256:5529227aba2b1377d900e9274a3f24496c645e65400fbae3cad5789944bc4d0b"}, + {file = "django_allauth-65.15.0-py3-none-any.whl", hash = "sha256:ad9fc49c49a9368eaa5bb95456b76e2a4f377b3c6862ee8443507816578c098d"}, + {file = "django_allauth-65.15.0.tar.gz", hash = "sha256:b404d48cf0c3ee14dacc834c541f30adedba2ff1c433980ecc494d6cb0b395a8"}, ] [package.dependencies] @@ -2837,20 +2827,20 @@ steam = ["python3-openid (>=3.0.8,<4)"] [[package]] name = "django-celery-beat" -version = "2.8.1" +version = "2.9.0" description = "Database-backed Periodic Tasks." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171"}, - {file = "django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a"}, + {file = "django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157"}, + {file = "django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c"}, ] [package.dependencies] celery = ">=5.2.3,<6.0" -cron-descriptor = ">=1.2.32" -Django = ">=2.2,<6.0" +cron-descriptor = ">=1.2.32,<2.0.0" +Django = ">=2.2,<6.1" django-timezone-field = ">=5.0" python-crontab = ">=2.3.4" tzdata = "*" @@ -3095,21 +3085,6 @@ docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] ssh = ["paramiko (>=2.4.3)"] websockets = ["websocket-client (>=1.3.0)"] -[[package]] -name = "dockerfile" -version = "3.4.0" -description = "Parse a dockerfile into a high-level representation using the official go parser." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "dockerfile-3.4.0-cp39-abi3-macosx_13_0_x86_64.whl", hash = "sha256:ed33446a76007cbb3f28c247f189cc06db34667d4f59a398a5c44912d7c13f36"}, - {file = "dockerfile-3.4.0-cp39-abi3-macosx_14_0_arm64.whl", hash = "sha256:a4549d4f038483c25906d4fec56bb6ffe82ae26e0f80a15f2c0fedbb50712053"}, - {file = "dockerfile-3.4.0-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:b95102bd82e6f67c836186b51c13114aa586a20e8cb6441bde24d4070542009d"}, - {file = "dockerfile-3.4.0-cp39-abi3-win_amd64.whl", hash = "sha256:30202187f1885f99ac839fd41ca8150b2fd0a66fac12db0166361d0c4622e71a"}, - {file = "dockerfile-3.4.0.tar.gz", hash = "sha256:238bb950985c55a525daef8bbfe994a0230aa0978c419f4caa4d9ce0a37343f1"}, -] - [[package]] name = "dogpile-cache" version = "1.5.0" @@ -3373,14 +3348,14 @@ files = [ [[package]] name = "flask" -version = "3.1.2" +version = "3.1.3" description = "A simple framework for building complex web applications." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"}, - {file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"}, + {file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"}, + {file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"}, ] [package.dependencies] @@ -3397,62 +3372,62 @@ dotenv = ["python-dotenv"] [[package]] name = "fonttools" -version = "4.61.1" +version = "4.62.1" description = "Tools to manipulate font files" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24"}, - {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958"}, - {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da"}, - {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6"}, - {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1"}, - {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881"}, - {file = "fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47"}, - {file = "fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6"}, - {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09"}, - {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37"}, - {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb"}, - {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9"}, - {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87"}, - {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56"}, - {file = "fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a"}, - {file = "fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7"}, - {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e"}, - {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2"}, - {file = "fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796"}, - {file = "fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d"}, - {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8"}, - {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0"}, - {file = "fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261"}, - {file = "fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9"}, - {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c"}, - {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e"}, - {file = "fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5"}, - {file = "fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd"}, - {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3"}, - {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d"}, - {file = "fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c"}, - {file = "fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b"}, - {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd"}, - {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e"}, - {file = "fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c"}, - {file = "fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75"}, - {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063"}, - {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2"}, - {file = "fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c"}, - {file = "fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c"}, - {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa"}, - {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91"}, - {file = "fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19"}, - {file = "fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba"}, - {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7"}, - {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118"}, - {file = "fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5"}, - {file = "fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b"}, - {file = "fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371"}, - {file = "fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69"}, + {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"}, + {file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"}, + {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"}, + {file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"}, + {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"}, + {file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"}, + {file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"}, + {file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"}, + {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"}, + {file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"}, + {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"}, + {file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"}, + {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"}, + {file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"}, + {file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"}, + {file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"}, + {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"}, + {file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"}, + {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"}, + {file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"}, + {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"}, + {file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"}, + {file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"}, + {file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"}, + {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"}, + {file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"}, + {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"}, + {file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"}, + {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"}, + {file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"}, + {file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"}, + {file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"}, + {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"}, + {file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"}, + {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"}, + {file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"}, + {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"}, + {file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"}, + {file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"}, + {file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"}, + {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"}, + {file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"}, + {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"}, + {file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"}, + {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"}, + {file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"}, + {file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"}, + {file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"}, + {file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"}, + {file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"}, ] [package.extras] @@ -5067,18 +5042,18 @@ tests = ["psutil", "pytest (!=3.3.0)", "pytest-cov"] [[package]] name = "markdown" -version = "3.9" +version = "3.10.2" description = "Python implementation of John Gruber's Markdown." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280"}, - {file = "markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a"}, + {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, + {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, ] [package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] testing = ["coverage", "pyyaml"] [[package]] @@ -5852,14 +5827,14 @@ files = [ [[package]] name = "nltk" -version = "3.9.2" +version = "3.9.4" description = "Natural Language Toolkit" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"}, - {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"}, + {file = "nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f"}, + {file = "nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0"}, ] [package.dependencies] @@ -6657,10 +6632,10 @@ files = [ [[package]] name = "prowler" -version = "5.19.0" +version = "5.23.0" description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks." optional = false -python-versions = ">3.9.1,<3.13" +python-versions = ">=3.10,<3.13" groups = ["main"] files = [] develop = false @@ -6715,6 +6690,7 @@ colorama = "0.4.6" cryptography = "44.0.3" dash = "3.1.1" dash-bootstrap-components = "2.0.3" +defusedxml = ">=0.7.1" detect-secrets = "1.5.0" dulwich = "0.23.0" google-api-python-client = "2.163.0" @@ -6722,7 +6698,7 @@ google-auth-httplib2 = ">=0.1,<0.3" h2 = "4.3.0" jsonschema = "4.23.0" kubernetes = "32.0.1" -markdown = "3.9.0" +markdown = "3.10.2" microsoft-kiota-abstractions = "1.9.2" msgraph-sdk = "1.23.0" numpy = "2.0.2" @@ -6732,7 +6708,7 @@ pandas = "2.2.3" py-iam-expand = "0.1.0" py-ocsf-models = "0.8.1" pydantic = ">=2.0,<3.0" -pygithub = "2.5.0" +pygithub = "2.8.0" python-dateutil = ">=2.9.0.post0,<3.0.0" pytz = "2025.1" schema = "0.7.5" @@ -6740,12 +6716,13 @@ shodan = "1.31.0" slack-sdk = "3.39.0" tabulate = "0.9.0" tzlocal = "5.3.1" +uuid6 = "2024.7.10" [package.source] type = "git" url = "https://github.com/prowler-cloud/prowler.git" reference = "master" -resolved_reference = "6962622fd21401886371add25463f77228cd9c1f" +resolved_reference = "6ac90eb1b58590b6f2f51645dbef17b9231053f4" [[package]] name = "psutil" @@ -7118,22 +7095,21 @@ typing-extensions = ">=4.14.1" [[package]] name = "pygithub" -version = "2.5.0" +version = "2.8.0" description = "Use the full Github API v3" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"}, - {file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"}, + {file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"}, + {file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"}, ] [package.dependencies] -Deprecated = "*" pyjwt = {version = ">=2.4.0", extras = ["crypto"]} pynacl = ">=1.4.0" requests = ">=2.14.0" -typing-extensions = ">=4.0.0" +typing-extensions = ">=4.5.0" urllib3 = ">=1.26.0" [[package]] @@ -7369,14 +7345,14 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments [[package]] name = "pytest-celery" -version = "1.2.1" +version = "1.3.0" description = "Pytest plugin for Celery" optional = false -python-versions = "<4.0,>=3.8" +python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "pytest_celery-1.2.1-py3-none-any.whl", hash = "sha256:0441ab0c2a712b775be16ffda3d7deb31995fd7b5e9d71630e7ea98b474346a3"}, - {file = "pytest_celery-1.2.1.tar.gz", hash = "sha256:7873fb3cf4fbfe9b0dd15d359bdb8bbab4a41c7e48f5b0adb7d36138d3704d52"}, + {file = "pytest_celery-1.3.0-py3-none-any.whl", hash = "sha256:f02201d7770584a0c412a1ded329a142170c24012467c7046f2c72cc8205ad5d"}, + {file = "pytest_celery-1.3.0.tar.gz", hash = "sha256:bd9e5b0f594ec5de9ab97cf27e3a11c644718a761bab6b997d01800fd7394f64"}, ] [package.dependencies] @@ -7387,14 +7363,13 @@ kombu = "*" psutil = ">=7.0.0" pytest-docker-tools = ">=3.1.3" redis = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"redis\""} -setuptools = {version = ">=75.8.0", markers = "python_version >= \"3.9\" and python_version < \"4.0\""} tenacity = ">=9.0.0" [package.extras] -all = ["boto3", "botocore", "python-memcached", "redis", "urllib3 (>=1.26.16,<2.0)"] +all = ["boto3", "botocore", "pycurl (>=7.43) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "python-memcached", "redis", "urllib3 (>=1.26.16,<2.0)"] memcached = ["python-memcached"] redis = ["redis"] -sqs = ["boto3", "botocore", "urllib3 (>=1.26.16,<2.0)"] +sqs = ["boto3", "botocore", "pycurl (>=7.43) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16,<2.0)"] [[package]] name = "pytest-cov" @@ -7879,14 +7854,14 @@ files = [ [[package]] name = "reportlab" -version = "4.4.9" +version = "4.4.10" description = "The Reportlab Toolkit" optional = false python-versions = "<4,>=3.9" groups = ["main"] files = [ - {file = "reportlab-4.4.9-py3-none-any.whl", hash = "sha256:68e2d103ae8041a37714e8896ec9b79a1c1e911d68c3bd2ea17546568cf17bfd"}, - {file = "reportlab-4.4.9.tar.gz", hash = "sha256:7cf487764294ee791a4781f5a157bebce262a666ae4bbb87786760a9676c9378"}, + {file = "reportlab-4.4.10-py3-none-any.whl", hash = "sha256:5abc815746ae2bc44e7ff25db96814f921349ca814c992c7eac3c26029bf7c24"}, + {file = "reportlab-4.4.10.tar.gz", hash = "sha256:5cbbb34ac3546039d0086deb2938cdec06b12da3cdb836e813258eb33cd28487"}, ] [package.dependencies] @@ -8308,14 +8283,14 @@ contextlib2 = ">=0.5.5" [[package]] name = "sentry-sdk" -version = "2.51.0" +version = "2.56.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f"}, - {file = "sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7"}, + {file = "sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02"}, + {file = "sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168"}, ] [package.dependencies] @@ -8788,14 +8763,14 @@ test = ["pytest", "websockets"] [[package]] name = "werkzeug" -version = "3.1.5" +version = "3.1.7" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, - {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, + {file = "werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f"}, + {file = "werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351"}, ] [package.dependencies] @@ -9397,4 +9372,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "42759b370c9e38da727e73f9d8ec0fa61bc6137eab18f11ccd7deff79a0dee69" +content-hash = "167d4549788b8bc8bb7772b9a81ade1eab73d8f354251a8d6af4901223cc7f67" diff --git a/api/pyproject.toml b/api/pyproject.toml index e417a6ca27..45e7899f7a 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -5,43 +5,44 @@ requires = ["poetry-core"] [project] authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] dependencies = [ - "celery (>=5.4.0,<6.0.0)", + "celery (==5.6.2)", "dj-rest-auth[with_social,jwt] (==7.0.1)", "django (==5.1.15)", - "django-allauth[saml] (>=65.13.0,<66.0.0)", - "django-celery-beat (>=2.7.0,<3.0.0)", - "django-celery-results (>=2.5.1,<3.0.0)", + "django-allauth[saml] (==65.15.0)", + "django-celery-beat (==2.9.0)", + "django-celery-results (==2.6.0)", "django-cors-headers==4.4.0", "django-environ==0.11.2", "django-filter==24.3", "django-guid==3.5.0", - "django-postgres-extra (>=2.0.8,<3.0.0)", + "django-postgres-extra (==2.0.9)", "djangorestframework==3.15.2", "djangorestframework-jsonapi==7.0.2", - "djangorestframework-simplejwt (>=5.3.1,<6.0.0)", - "drf-nested-routers (>=0.94.1,<1.0.0)", + "djangorestframework-simplejwt (==5.5.1)", + "drf-nested-routers (==0.95.0)", "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", + "defusedxml==0.7.1", "gunicorn==23.0.0", "lxml==5.3.2", "prowler @ git+https://github.com/prowler-cloud/prowler.git@master", "psycopg2-binary==2.9.9", - "pytest-celery[redis] (>=1.0.1,<2.0.0)", - "sentry-sdk[django] (>=2.20.0,<3.0.0)", + "pytest-celery[redis] (==1.3.0)", + "sentry-sdk[django] (==2.56.0)", "uuid6==2024.7.10", - "openai (>=1.82.0,<2.0.0)", + "openai (==1.109.1)", "xmlsec==1.3.14", "h2 (==4.3.0)", - "markdown (>=3.9,<4.0)", + "markdown (==3.10.2)", "drf-simple-apikey (==2.2.1)", - "matplotlib (>=3.10.6,<4.0.0)", - "reportlab (>=4.4.4,<5.0.0)", - "neo4j (>=6.0.0,<7.0.0)", - "cartography (==0.129.0)", - "gevent (>=25.9.1,<26.0.0)", - "werkzeug (>=3.1.4)", - "sqlparse (>=0.5.4)", - "fonttools (>=4.60.2)" + "matplotlib (==3.10.8)", + "reportlab (==4.4.10)", + "neo4j (==6.1.0)", + "cartography (==0.132.0)", + "gevent (==25.9.1)", + "werkzeug (==3.1.7)", + "sqlparse (==0.5.5)", + "fonttools (==4.62.1)" ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" @@ -49,7 +50,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.20.0" +version = "1.24.0" [project.scripts] celery = "src.backend.config.settings.celery" @@ -61,7 +62,7 @@ django-silk = "5.3.2" docker = "7.1.0" filelock = "3.20.3" freezegun = "1.5.1" -marshmallow = ">=3.15.0,<4.0.0" +marshmallow = "==3.26.2" mypy = "1.10.1" pylint = "3.2.5" pytest = "8.2.2" diff --git a/api/src/backend/api/attack_paths/cypher_sanitizer.py b/api/src/backend/api/attack_paths/cypher_sanitizer.py new file mode 100644 index 0000000000..3772b4cbef --- /dev/null +++ b/api/src/backend/api/attack_paths/cypher_sanitizer.py @@ -0,0 +1,170 @@ +""" +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``). + +2. **Provider-scoped label injection** - inject a dynamic + ``_Provider_{uuid}`` label into every node pattern so the database can + use its native label index for provider isolation. + +Label-injection pipeline: + +1. **Protect** string literals and line comments (placeholder replacement). +2. **Split** by top-level clause keywords to track clause context. +3. **Pass A** - inject into *labeled* node patterns in ALL segments. +4. **Pass B** - inject into *bare* node patterns in MATCH segments only. +5. **Restore** protected regions. +""" + +import re + +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. +# 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. +_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*") + +# Step 2 - Clause splitting +# 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", + re.IGNORECASE, +) + +# Pass A - Labeled node patterns (all segments) +# Matches node patterns that have at least one :Label. +# (? str: + """Inject provider label into all node patterns that have existing labels.""" + return _LABELED_NODE_RE.sub(rf"(\1:{label}\2", segment) + + +def _inject_bare(segment: str, label: str) -> str: + """Inject provider label into bare `(identifier)` node patterns.""" + + def _replace(match): + var = match.group(1) + props = match.group(2).strip() + if props: + return f"({var}:{label} {props})" + return f"({var}:{label})" + + return _BARE_NODE_RE.sub(_replace, segment) + + +def inject_provider_label(cypher: str, provider_id: str) -> str: + """Rewrite a Cypher query to scope every node pattern to a provider. + + Args: + cypher: The original Cypher query string. + provider_id: The provider UUID (will be converted to a label via + `get_provider_label`). + + Returns: + The rewritten Cypher with `:_Provider_{uuid}` appended to every + node pattern. + """ + label = get_provider_label(provider_id) + + # Step 1: Protect strings and comments (single pass, leftmost-first) + protected: list[str] = [] + + def _save(match): + protected.append(match.group(0)) + return f"\x00P{len(protected) - 1}\x00" + + work = _PROTECTED_RE.sub(_save, cypher) + + # Step 2: Split by clause keywords + parts = _CLAUSE_RE.split(work) + + # Steps 3-4: Apply injection passes per segment + result: list[str] = [] + current_clause: str | None = None + + for i, part in enumerate(parts): + if i % 2 == 1: + # Keyword token - normalize for clause tracking + current_clause = re.sub(r"\s+", " ", part.strip()).upper() + result.append(part) + else: + # Content segment - apply injection based on clause context + part = _inject_labeled(part, label) + if current_clause in _MATCH_CLAUSES: + part = _inject_bare(part, label) + result.append(part) + + work = "".join(result) + + # Step 5: Restore protected regions + for i, original in enumerate(protected): + work = work.replace(f"\x00P{i}\x00", original) + + return work + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +# Patterns that indicate SSRF or dangerous procedure calls +# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS` +_BLOCKED_PATTERNS = [ + re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE), + re.compile(r"\bapoc\.load\b", re.IGNORECASE), + re.compile(r"\bapoc\.import\b", re.IGNORECASE), + re.compile(r"\bapoc\.export\b", re.IGNORECASE), + re.compile(r"\bapoc\.cypher\b", re.IGNORECASE), + re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE), + re.compile(r"\bapoc\.config\b", re.IGNORECASE), + re.compile(r"\bapoc\.periodic\b", re.IGNORECASE), + re.compile(r"\bapoc\.do\b", re.IGNORECASE), + re.compile(r"\bapoc\.trigger\b", re.IGNORECASE), + re.compile(r"\bapoc\.custom\b", re.IGNORECASE), +] + + +def validate_custom_query(cypher: str) -> None: + """Reject queries containing known SSRF or dangerous procedure patterns. + + Raises ValidationError if a blocked pattern is found. + String literals and comments are stripped before matching to avoid + false positives. + """ + stripped = _PROTECTED_RE.sub("", cypher) + for pattern in _BLOCKED_PATTERNS: + if pattern.search(stripped): + raise ValidationError({"query": "Query contains a blocked operation"}) diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index 418652f79c..f8e20b659e 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -1,25 +1,22 @@ import atexit import logging import threading - -from typing import Any - from contextlib import contextmanager -from typing import Iterator +from typing import Any, Iterator from uuid import UUID import neo4j import neo4j.exceptions - -from django.conf import settings - -from api.attack_paths.retryable_session import RetryableSession from config.env import env +from django.conf import settings from tasks.jobs.attack_paths.config import ( BATCH_SIZE, - DEPRECATED_PROVIDER_RESOURCE_LABEL, + PROVIDER_RESOURCE_LABEL, + get_provider_label, ) +from api.attack_paths.retryable_session import RetryableSession + # Without this Celery goes crazy with Neo4j logging logging.getLogger("neo4j").setLevel(logging.ERROR) logging.getLogger("neo4j").propagate = False @@ -35,6 +32,7 @@ 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 @@ -108,6 +106,7 @@ def get_session( 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" @@ -115,6 +114,10 @@ def get_session( 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: @@ -160,11 +163,8 @@ def drop_subgraph(database: str, provider_id: str) -> int: Uses batched deletion to avoid memory issues with large graphs. Silently returns 0 if the database doesn't exist. """ + provider_label = get_provider_label(provider_id) deleted_nodes = 0 - parameters = { - "provider_id": provider_id, - "batch_size": BATCH_SIZE, - } try: with get_session(database) as session: @@ -172,12 +172,12 @@ def drop_subgraph(database: str, provider_id: str) -> int: while deleted_count > 0: result = session.run( f""" - MATCH (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}}) + MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) WITH n LIMIT $batch_size DETACH DELETE n RETURN COUNT(n) AS deleted_nodes_count """, - parameters, + {"batch_size": BATCH_SIZE}, ) deleted_count = result.single().get("deleted_nodes_count", 0) deleted_nodes += deleted_count @@ -190,6 +190,26 @@ def drop_subgraph(database: str, provider_id: str) -> int: 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()" @@ -227,3 +247,7 @@ class GraphDatabaseQueryException(Exception): class WriteQueryNotAllowedException(GraphDatabaseQueryException): pass + + +class ClientStatementException(GraphDatabaseQueryException): + pass diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index a54bd664ca..f50935c49f 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -16,9 +16,6 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( 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""" - CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet', provider_id: $provider_id}}) - YIELD node AS internet - 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) @@ -32,13 +29,18 @@ AWS_INTERNET_EXPOSED_EC2_SENSITIVE_S3_ACCESS = AttackPathsQueryDefinition( MATCH path_assume_role = (ec2)-[p:STS_ASSUMEROLE_ALLOW*1..9]-(r:AWSRole) - CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{provider_id: $provider_id}}, ec2) - YIELD rel AS can_access + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) - UNWIND nodes(path_s3) + nodes(path_ec2) + nodes(path_role) + nodes(path_assume_role) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + 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 - RETURN path_s3, path_ec2, path_role, path_assume_role, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + 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( @@ -69,10 +71,15 @@ AWS_RDS_INSTANCES = AttackPathsQueryDefinition( cypher=f""" MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -87,10 +94,15 @@ AWS_RDS_UNENCRYPTED_STORAGE = AttackPathsQueryDefinition( MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(rds:RDSInstance) WHERE rds.storage_encrypted = false - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -105,10 +117,15 @@ AWS_S3_ANONYMOUS_ACCESS_BUCKETS = AttackPathsQueryDefinition( MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(s3:S3Bucket) WHERE s3.anonymous_access = true - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -124,10 +141,15 @@ AWS_IAM_STATEMENTS_ALLOW_ALL_ACTIONS = AttackPathsQueryDefinition( WHERE stmt.effect = 'Allow' AND any(x IN stmt.action WHERE x = '*') - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -143,10 +165,15 @@ AWS_IAM_STATEMENTS_ALLOW_DELETE_POLICY = AttackPathsQueryDefinition( WHERE stmt.effect = 'Allow' AND any(x IN stmt.action WHERE x = "iam:DeletePolicy") - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -162,10 +189,15 @@ AWS_IAM_STATEMENTS_ALLOW_CREATE_ACTIONS = AttackPathsQueryDefinition( WHERE stmt.effect = "Allow" AND any(x IN stmt.action WHERE toLower(x) CONTAINS "create") - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -181,19 +213,20 @@ AWS_EC2_INSTANCES_INTERNET_EXPOSED = AttackPathsQueryDefinition( description="Find EC2 instances flagged as exposed to the internet within the selected account.", provider="aws", cypher=f""" - CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet', provider_id: $provider_id}}) - YIELD node AS internet - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance) WHERE ec2.exposed_internet = true - CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{provider_id: $provider_id}}, ec2) - YIELD rel AS can_access + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + 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 - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + 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=[], ) @@ -205,21 +238,21 @@ AWS_SECURITY_GROUPS_OPEN_INTERNET_FACING = AttackPathsQueryDefinition( description="Find internet-facing resources associated with security groups that allow inbound access from '0.0.0.0/0'.", provider="aws", cypher=f""" - CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet', provider_id: $provider_id}}) - YIELD node AS internet - - // Match EC2 instances that are internet-exposed with open security groups (0.0.0.0/0) - MATCH path_ec2 = (aws:AWSAccount {{id: $provider_uid}})--(ec2:EC2Instance)--(sg:EC2SecurityGroup)--(ipi:IpPermissionInbound)--(ir:IpRange) + 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" - CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{provider_id: $provider_id}}, ec2) - YIELD rel AS can_access + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2) - UNWIND nodes(path_ec2) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + 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 - RETURN path_ec2, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + 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=[], ) @@ -231,19 +264,20 @@ AWS_CLASSIC_ELB_INTERNET_EXPOSED = AttackPathsQueryDefinition( description="Find Classic Load Balancers exposed to the internet along with their listeners.", provider="aws", cypher=f""" - CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet', provider_id: $provider_id}}) - YIELD node AS internet - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elb:LoadBalancer)--(listener:ELBListener) WHERE elb.exposed_internet = true - CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{provider_id: $provider_id}}, elb) - YIELD rel AS can_access + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elb) - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + 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 - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + 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=[], ) @@ -255,19 +289,20 @@ AWS_ELBV2_INTERNET_EXPOSED = AttackPathsQueryDefinition( description="Find ELBv2 load balancers exposed to the internet along with their listeners.", provider="aws", cypher=f""" - CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet', provider_id: $provider_id}}) - YIELD node AS internet - MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(elbv2:LoadBalancerV2)--(listener:ELBV2Listener) WHERE elbv2.exposed_internet = true - CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{provider_id: $provider_id}}, elbv2) - YIELD rel AS can_access + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(elbv2) - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + 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 - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + 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=[], ) @@ -279,36 +314,23 @@ AWS_PUBLIC_IP_RESOURCE_LOOKUP = AttackPathsQueryDefinition( description="Given a public IP address, find the related AWS resource and its adjacent node within the selected account.", provider="aws", cypher=f""" - CALL apoc.create.vNode(['Internet'], {{id: 'Internet', name: 'Internet', provider_id: $provider_id}}) - YIELD node AS internet + 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) - CALL () {{ - MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:EC2PrivateIp)-[q]-(y) - WHERE x.public_ip = $ip - RETURN path, x + OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(x) - UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:EC2Instance)-[q]-(y) - WHERE x.publicipaddress = $ip - RETURN path, 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 - UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:NetworkInterface)-[q]-(y) - WHERE x.public_ip = $ip - RETURN path, x + 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'}}) - UNION MATCH path = (aws:AWSAccount {{id: $provider_uid}})-[r]-(x:ElasticIPAddress)-[q]-(y) - WHERE x.public_ip = $ip - RETURN path, x - }} - - WITH path, x, internet - - CALL apoc.create.vRelationship(internet, 'CAN_ACCESS', {{provider_id: $provider_id}}, x) - YIELD rel AS can_access - - UNWIND nodes(path) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) - - RETURN path, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr, internet, can_access """, parameters=[ AttackPathsQueryParameterDefinition( @@ -362,11 +384,16 @@ AWS_APPRUNNER_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -395,11 +422,16 @@ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition( // 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'}}) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -460,11 +492,16 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -502,11 +539,16 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( // Find roles that trust Bedrock service (already attached to existing code interpreters) MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock.amazonaws.com'}}) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -549,11 +591,16 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACK = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -582,11 +629,16 @@ AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition( // 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'}}) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -638,11 +690,16 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_CREATE_STACKSET = AttackPathsQueryDefinition OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -685,11 +742,16 @@ AWS_CLOUDFORMATION_PRIVESC_PASSROLE_UPDATE_STACKSET = AttackPathsQueryDefinition OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -727,11 +789,16 @@ AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition( // 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'}}) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -783,11 +850,16 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -816,11 +888,16 @@ AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition( // 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'}}) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -849,11 +926,16 @@ AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition( // 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'}}) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -905,11 +987,16 @@ AWS_CODEBUILD_PRIVESC_PASSROLE_CREATE_PROJECT_BATCH = AttackPathsQueryDefinition OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -971,11 +1058,16 @@ AWS_DATAPIPELINE_PRIVESC_PASSROLE_CREATE_PIPELINE = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1018,11 +1110,16 @@ AWS_EC2_PRIVESC_PASSROLE_IAM = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1069,11 +1166,16 @@ AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition( // Find EC2 instances with instance profiles (potential targets) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1116,11 +1218,16 @@ AWS_EC2_PRIVESC_PASSROLE_SPOT_INSTANCES = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1158,11 +1265,16 @@ AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition( // Find launch templates in the account (potential targets) MATCH path_target = (aws)--(template:LaunchTemplate) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1191,11 +1303,16 @@ AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition( // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1256,11 +1373,16 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1321,11 +1443,16 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1377,11 +1504,16 @@ AWS_ECS_PRIVESC_PASSROLE_CREATE_SERVICE_EXISTING_CLUSTER = AttackPathsQueryDefin OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1433,11 +1565,16 @@ AWS_ECS_PRIVESC_PASSROLE_RUN_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1489,11 +1626,16 @@ AWS_ECS_PRIVESC_PASSROLE_START_TASK_EXISTING_CLUSTER = AttackPathsQueryDefinitio OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1531,11 +1673,16 @@ AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition( // 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'}}) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1578,11 +1725,16 @@ AWS_GLUE_PRIVESC_PASSROLE_DEV_ENDPOINT = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1611,11 +1763,16 @@ AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition( // 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'}}) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1667,11 +1824,16 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1723,11 +1885,16 @@ AWS_GLUE_PRIVESC_PASSROLE_CREATE_JOB_TRIGGER = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1779,11 +1946,16 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1835,11 +2007,16 @@ AWS_GLUE_PRIVESC_PASSROLE_UPDATE_JOB_TRIGGER = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1873,11 +2050,16 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION = AttackPathsQueryDefinition( OR target_policy.arn CONTAINS resource ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1911,11 +2093,16 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR resource CONTAINS target_user.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -1963,11 +2150,16 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR resource CONTAINS target_user.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2001,11 +2193,16 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition( OR resource CONTAINS target_user.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2023,7 +2220,7 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find roles with iam:PutRolePolicy permission scoped to themselves - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + 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' @@ -2036,11 +2233,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY = AttackPathsQueryDefinition( OR resource CONTAINS role.name ) - UNWIND nodes(path_principal) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2074,11 +2276,16 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition( OR resource CONTAINS target_user.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2096,7 +2303,7 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:PutUserPolicy permission scoped to themselves - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + 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' @@ -2109,11 +2316,16 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY = AttackPathsQueryDefinition( OR resource CONTAINS user.name ) - UNWIND nodes(path_principal) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2131,7 +2343,7 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find users with iam:AttachUserPolicy permission scoped to themselves - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(user:AWSUser)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + 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' @@ -2144,11 +2356,16 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY = AttackPathsQueryDefinition( OR resource CONTAINS user.name ) - UNWIND nodes(path_principal) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2166,7 +2383,7 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( provider="aws", cypher=f""" // Find roles with iam:AttachRolePolicy permission scoped to themselves - MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(role:AWSRole)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) + 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' @@ -2179,11 +2396,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY = AttackPathsQueryDefinition( OR resource CONTAINS role.name ) - UNWIND nodes(path_principal) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2217,11 +2439,16 @@ AWS_IAM_PRIVESC_ATTACH_GROUP_POLICY = AttackPathsQueryDefinition( OR resource CONTAINS target_group.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2255,11 +2482,16 @@ AWS_IAM_PRIVESC_PUT_GROUP_POLICY = AttackPathsQueryDefinition( OR resource CONTAINS target_group.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2293,11 +2525,16 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2331,11 +2568,16 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition( OR resource CONTAINS target_group.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2369,11 +2611,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2421,11 +2668,16 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio OR resource CONTAINS target_user.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2460,11 +2712,16 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_ASSUME_ROLE = AttackPathsQueryDefinition( OR target_policy.arn CONTAINS resource ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2498,11 +2755,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_ASSUME_ROLE = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2550,11 +2812,16 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition( OR resource CONTAINS target_user.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2602,11 +2869,16 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2655,11 +2927,16 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin OR target_policy.arn CONTAINS resource ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2707,11 +2984,16 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2763,11 +3045,16 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2819,11 +3106,16 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_EVENT_SOURCE = AttackPathsQueryDefin OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2857,11 +3149,16 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE = AttackPathsQueryDefinition( OR resource CONTAINS lambda_fn.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2909,11 +3206,16 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_INVOKE = AttackPathsQueryDefinition( OR resource CONTAINS lambda_fn.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -2961,11 +3263,16 @@ AWS_LAMBDA_PRIVESC_UPDATE_FUNCTION_CODE_ADD_PERMISSION = AttackPathsQueryDefinit OR resource CONTAINS lambda_fn.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3017,11 +3324,16 @@ AWS_LAMBDA_PRIVESC_PASSROLE_CREATE_FUNCTION_ADD_PERMISSION = AttackPathsQueryDef OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3064,11 +3376,16 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_NOTEBOOK = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3111,11 +3428,16 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_TRAINING_JOB = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3158,11 +3480,16 @@ AWS_SAGEMAKER_PRIVESC_PASSROLE_CREATE_PROCESSING_JOB = AttackPathsQueryDefinitio OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3196,11 +3523,16 @@ AWS_SAGEMAKER_PRIVESC_PRESIGNED_NOTEBOOK_URL = AttackPathsQueryDefinition( OR resource CONTAINS notebook.notebook_instance_name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3261,11 +3593,16 @@ AWS_SAGEMAKER_PRIVESC_LIFECYCLE_CONFIG_NOTEBOOK = AttackPathsQueryDefinition( OR resource CONTAINS notebook.notebook_instance_name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3294,11 +3631,16 @@ AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition( // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3327,11 +3669,16 @@ AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition( // Find EC2 instances with attached roles (targets for credential theft via IMDS) MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) @@ -3365,11 +3712,16 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition( OR resource CONTAINS target_role.name ) - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + 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=[], ) diff --git a/api/src/backend/api/attack_paths/queries/schema.py b/api/src/backend/api/attack_paths/queries/schema.py index 1ed227458b..5373d17508 100644 --- a/api/src/backend/api/attack_paths/queries/schema.py +++ b/api/src/backend/api/attack_paths/queries/schema.py @@ -1,13 +1,18 @@ -from tasks.jobs.attack_paths.config import DEPRECATED_PROVIDER_RESOURCE_LABEL +from tasks.jobs.attack_paths.config import PROVIDER_RESOURCE_LABEL, get_provider_label + + +def get_cartography_schema_query(provider_id: str) -> str: + """Build the Cartography schema metadata query scoped to a provider label.""" + provider_label = get_provider_label(provider_id) + return f""" + MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) + WHERE n._module_name STARTS WITH 'cartography:' + AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler'] + AND n._module_version IS NOT NULL + RETURN n._module_name AS module_name, n._module_version AS module_version + LIMIT 1 + """ -CARTOGRAPHY_SCHEMA_METADATA = f""" - MATCH (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}}) - WHERE n._module_name STARTS WITH 'cartography:' - AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler'] - AND n._module_version IS NOT NULL - RETURN n._module_name AS module_name, n._module_version AS module_version - LIMIT 1 -""" GITHUB_SCHEMA_URL = ( "https://github.com/cartography-cncf/cartography/blob/" diff --git a/api/src/backend/api/attack_paths/views_helpers.py b/api/src/backend/api/attack_paths/views_helpers.py index 8cba56eff7..201527885e 100644 --- a/api/src/backend/api/attack_paths/views_helpers.py +++ b/api/src/backend/api/attack_paths/views_helpers.py @@ -3,16 +3,26 @@ import logging from typing import Any, Iterable import neo4j + from rest_framework.exceptions import APIException, PermissionDenied, ValidationError from api.attack_paths import database as graph_database, AttackPathsQueryDefinition +from api.attack_paths.cypher_sanitizer import ( + inject_provider_label, + validate_custom_query, +) from api.attack_paths.queries.schema import ( - CARTOGRAPHY_SCHEMA_METADATA, GITHUB_SCHEMA_URL, RAW_SCHEMA_URL, + get_cartography_schema_query, ) from config.custom_logging import BackendLogger -from tasks.jobs.attack_paths.config import INTERNAL_LABELS, INTERNAL_PROPERTIES +from tasks.jobs.attack_paths.config import ( + INTERNAL_LABELS, + INTERNAL_PROPERTIES, + get_provider_label, + is_dynamic_isolation_label, +) logger = logging.getLogger(BackendLogger.API) @@ -66,7 +76,6 @@ def prepare_parameters( clean_parameters = { "provider_uid": str(provider_uid), - "provider_id": str(provider_id), } for definition_parameter in definition.parameters: @@ -135,6 +144,16 @@ def execute_custom_query( cypher: str, provider_id: str, ) -> 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 + # + # 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) + try: graph = graph_database.execute_read_query( database=database_name, @@ -143,6 +162,9 @@ def execute_custom_query( serialized = _serialize_graph(graph, provider_id) return _truncate_graph(serialized) + except graph_database.ClientStatementException as exc: + raise ValidationError({"query": exc.message}) + except graph_database.WriteQueryNotAllowedException: raise PermissionDenied( "Attack Paths query execution failed: read-only queries are enforced" @@ -165,10 +187,7 @@ def get_cartography_schema( with graph_database.get_session( database_name, default_access_mode=neo4j.READ_ACCESS ) as session: - result = session.run( - CARTOGRAPHY_SCHEMA_METADATA, - {"provider_id": provider_id}, - ) + result = session.run(get_cartography_schema_query(provider_id)) record = result.single() except graph_database.GraphDatabaseQueryException as exc: logger.error(f"Cartography schema query failed: {exc}") @@ -212,10 +231,12 @@ def _truncate_graph(graph: dict[str, Any]) -> dict[str, Any]: def _serialize_graph(graph, provider_id: str) -> dict[str, Any]: + provider_label = get_provider_label(provider_id) + nodes = [] kept_node_ids = set() for node in graph.nodes: - if node._properties.get("provider_id") != provider_id: + if provider_label not in node.labels: continue kept_node_ids.add(node.element_id) @@ -227,11 +248,14 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]: }, ) + filtered_count = len(graph.nodes) - len(nodes) + if filtered_count > 0: + logger.debug( + f"Filtered {filtered_count} nodes without provider label {provider_label}" + ) + relationships = [] for relationship in graph.relationships: - if relationship._properties.get("provider_id") != provider_id: - continue - if ( relationship.start_node.element_id not in kept_node_ids or relationship.end_node.element_id not in kept_node_ids @@ -257,7 +281,11 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]: def _filter_labels(labels: Iterable[str]) -> list[str]: - return [label for label in labels if label not in INTERNAL_LABELS] + return [ + label + for label in labels + if label not in INTERNAL_LABELS and not is_dynamic_isolation_label(label) + ] def _serialize_properties(properties: dict[str, Any]) -> dict[str, Any]: diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index 7cdf8fdd7f..b14cc39529 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -1,10 +1,10 @@ from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from rest_framework import permissions from rest_framework.exceptions import NotAuthenticated from rest_framework.filters import SearchFilter from rest_framework.permissions import SAFE_METHODS +from rest_framework.response import Response from rest_framework_json_api import filters from rest_framework_json_api.views import ModelViewSet @@ -12,7 +12,7 @@ from api.authentication import CombinedJWTOrAPIKeyAuthentication from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias from api.db_utils import POSTGRES_USER_VAR, rls_transaction from api.filters import CustomDjangoFilterBackend -from api.models import Role, Tenant +from api.models import Role, UserRoleRelationship from api.rbac.permissions import HasPermissions @@ -113,27 +113,22 @@ class BaseTenantViewset(BaseViewSet): if request is not None: request.db_alias = self.db_alias - with transaction.atomic(using=self.db_alias): - tenant = super().dispatch(request, *args, **kwargs) - - try: - # If the request is a POST, create the admin role - if request.method == "POST": - isinstance(tenant, dict) and self._create_admin_role( - tenant.data["id"] - ) - except Exception as e: - self._handle_creation_error(e, tenant) - raise - - return tenant + if request.method == "POST": + with transaction.atomic(using=MainRouter.admin_db): + tenant = super().dispatch(request, *args, **kwargs) + if isinstance(tenant, Response) and tenant.status_code == 201: + self._create_admin_role(tenant.data["id"]) + return tenant + else: + with transaction.atomic(using=self.db_alias): + return super().dispatch(request, *args, **kwargs) finally: if alias_token is not None: reset_read_db_alias(alias_token) self.db_alias = MainRouter.default_db def _create_admin_role(self, tenant_id): - Role.objects.using(MainRouter.admin_db).create( + admin_role = Role.objects.using(MainRouter.admin_db).create( name="admin", tenant_id=tenant_id, manage_users=True, @@ -144,15 +139,11 @@ class BaseTenantViewset(BaseViewSet): manage_scans=True, unlimited_visibility=True, ) - - def _handle_creation_error(self, error, tenant): - if tenant.data.get("id"): - try: - Tenant.objects.using(MainRouter.admin_db).filter( - id=tenant.data["id"] - ).delete() - except ObjectDoesNotExist: - pass # Tenant might not exist, handle gracefully + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=self.request.user, + role=admin_role, + tenant_id=tenant_id, + ) def initial(self, request, *args, **kwargs): if request.auth is None: diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index 7a71084ccd..e3b11d7084 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -18,6 +18,7 @@ from django.db import ( ) from django_celery_beat.models import PeriodicTask from psycopg2 import connect as psycopg2_connect +from psycopg2 import sql as psycopg2_sql from psycopg2.extensions import AsIs, new_type, register_adapter, register_type from rest_framework_json_api.serializers import ValidationError @@ -280,15 +281,23 @@ class PostgresEnumMigration: self.enum_values = enum_values def create_enum_type(self, apps, schema_editor): # noqa: F841 - string_enum_values = ", ".join([f"'{value}'" for value in self.enum_values]) with schema_editor.connection.cursor() as cursor: cursor.execute( - f"CREATE TYPE {self.enum_name} AS ENUM ({string_enum_values});" + psycopg2_sql.SQL("CREATE TYPE {} AS ENUM ({})").format( + psycopg2_sql.Identifier(self.enum_name), + psycopg2_sql.SQL(", ").join( + psycopg2_sql.Literal(v) for v in self.enum_values + ), + ) ) def drop_enum_type(self, apps, schema_editor): # noqa: F841 with schema_editor.connection.cursor() as cursor: - cursor.execute(f"DROP TYPE {self.enum_name};") + cursor.execute( + psycopg2_sql.SQL("DROP TYPE {}").format( + psycopg2_sql.Identifier(self.enum_name) + ) + ) class PostgresEnumField(models.Field): diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index a64cc0ea13..a496a0bf67 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -15,6 +15,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 api.constants import SEVERITY_ORDER from api.db_utils import ( FindingDeltaEnumField, InvitationStateEnumField, @@ -43,6 +44,7 @@ from api.models import ( ProviderGroup, ProviderSecret, Resource, + ResourceFindingMapping, ResourceTag, Role, Scan, @@ -196,17 +198,13 @@ class CommonFindingFilters(FilterSet): field_name="resource_services", lookup_expr="icontains" ) - resource_uid = CharFilter(field_name="resources__uid") - resource_uid__in = CharInFilter(field_name="resources__uid", lookup_expr="in") - resource_uid__icontains = CharFilter( - field_name="resources__uid", lookup_expr="icontains" - ) + resource_uid = CharFilter(method="filter_resource_uid") + resource_uid__in = CharInFilter(method="filter_resource_uid_in") + resource_uid__icontains = CharFilter(method="filter_resource_uid_icontains") - resource_name = CharFilter(field_name="resources__name") - resource_name__in = CharInFilter(field_name="resources__name", lookup_expr="in") - resource_name__icontains = CharFilter( - field_name="resources__name", lookup_expr="icontains" - ) + resource_name = CharFilter(method="filter_resource_name") + resource_name__in = CharInFilter(method="filter_resource_name_in") + resource_name__icontains = CharFilter(method="filter_resource_name_icontains") resource_type = CharFilter(method="filter_resource_type") resource_type__in = CharInFilter(field_name="resource_types", lookup_expr="overlap") @@ -264,6 +262,52 @@ class CommonFindingFilters(FilterSet): ) return queryset.filter(overall_query).distinct() + def filter_check_title_icontains(self, queryset, name, value): + # Resolve from the summary table (has check_title column + trigram + # GIN index) instead of scanning JSON in the findings table. + matching_check_ids = ( + FindingGroupDailySummary.objects.filter( + check_title__icontains=value, + ) + .values_list("check_id", flat=True) + .distinct() + ) + return queryset.filter(check_id__in=matching_check_ids) + + # --- Resource subquery filters --- + # Resolve resource → RFM → finding_ids first, then filter findings + # by id__in. This avoids a 3-way JOIN driven from the (huge) + # findings side and lets PostgreSQL start from the resources + # unique-constraint index instead. + + @staticmethod + def _finding_ids_for_resources(**lookup): + return ResourceFindingMapping.objects.filter( + resource__in=Resource.objects.filter(**lookup).values("id") + ).values("finding_id") + + def filter_resource_uid(self, queryset, name, value): + return queryset.filter(id__in=self._finding_ids_for_resources(uid=value)) + + def filter_resource_uid_in(self, queryset, name, value): + return queryset.filter(id__in=self._finding_ids_for_resources(uid__in=value)) + + def filter_resource_uid_icontains(self, queryset, name, value): + return queryset.filter( + id__in=self._finding_ids_for_resources(uid__icontains=value) + ) + + def filter_resource_name(self, queryset, name, value): + return queryset.filter(id__in=self._finding_ids_for_resources(name=value)) + + def filter_resource_name_in(self, queryset, name, value): + return queryset.filter(id__in=self._finding_ids_for_resources(name__in=value)) + + def filter_resource_name_icontains(self, queryset, name, value): + return queryset.filter( + id__in=self._finding_ids_for_resources(name__icontains=value) + ) + class TenantFilter(FilterSet): inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") @@ -390,6 +434,7 @@ class ScanFilter(ProviderRelationshipFilterSet): class Meta: model = Scan fields = { + "id": ["exact", "in"], "provider": ["exact", "in"], "name": ["exact", "icontains"], "started_at": ["gte", "lte"], @@ -803,11 +848,15 @@ class FindingGroupFilter(CommonFindingFilters): check_id = CharFilter(field_name="check_id", lookup_expr="exact") check_id__in = CharInFilter(field_name="check_id", lookup_expr="in") check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains") + check_title__icontains = CharFilter(method="filter_check_title_icontains") + scan = UUIDFilter(field_name="scan_id", lookup_expr="exact") + scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in") class Meta: model = Finding fields = { "check_id": ["exact", "in", "icontains"], + "scan": ["exact", "in"], } def filter_queryset(self, queryset): @@ -895,15 +944,31 @@ class LatestFindingGroupFilter(CommonFindingFilters): check_id = CharFilter(field_name="check_id", lookup_expr="exact") check_id__in = CharInFilter(field_name="check_id", lookup_expr="in") check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains") + check_title__icontains = CharFilter(method="filter_check_title_icontains") + scan = UUIDFilter(field_name="scan_id", lookup_expr="exact") + scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in") class Meta: model = Finding fields = { "check_id": ["exact", "in", "icontains"], + "scan": ["exact", "in"], } -class FindingGroupSummaryFilter(FilterSet): +class _CheckTitleToCheckIdMixin: + """Resolve check_title search to check_ids so all provider rows are kept.""" + + def filter_check_title_to_check_ids(self, queryset, name, value): + matching_check_ids = ( + queryset.filter(check_title__icontains=value) + .values_list("check_id", flat=True) + .distinct() + ) + return queryset.filter(check_id__in=matching_check_ids) + + +class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): """ Filter for FindingGroupDailySummary queries. @@ -926,6 +991,7 @@ class FindingGroupSummaryFilter(FilterSet): check_id = CharFilter(field_name="check_id", lookup_expr="exact") check_id__in = CharInFilter(field_name="check_id", lookup_expr="in") check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains") + check_title__icontains = CharFilter(method="filter_check_title_to_check_ids") # Provider filters provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact") @@ -1013,7 +1079,7 @@ class FindingGroupSummaryFilter(FilterSet): return dt -class LatestFindingGroupSummaryFilter(FilterSet): +class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): """ Filter for FindingGroupDailySummary /latest endpoint. @@ -1025,6 +1091,7 @@ class LatestFindingGroupSummaryFilter(FilterSet): check_id = CharFilter(field_name="check_id", lookup_expr="exact") check_id__in = CharInFilter(field_name="check_id", lookup_expr="in") check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains") + check_title__icontains = CharFilter(method="filter_check_title_to_check_ids") # Provider filters provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact") @@ -1042,6 +1109,98 @@ class LatestFindingGroupSummaryFilter(FilterSet): } +class FindingGroupAggregatedComputedFilter(FilterSet): + """Filter aggregated finding-group rows by computed status/severity/muted.""" + + STATUS_CHOICES = ( + ("FAIL", "Fail"), + ("PASS", "Pass"), + ("MUTED", "Muted"), + ) + + status = ChoiceFilter(method="filter_status", choices=STATUS_CHOICES) + status__in = CharInFilter(method="filter_status_in", lookup_expr="in") + severity = ChoiceFilter(method="filter_severity", choices=SeverityChoices) + severity__in = CharInFilter(method="filter_severity_in", lookup_expr="in") + include_muted = BooleanFilter(method="filter_include_muted") + + def filter_status(self, queryset, name, value): + return queryset.filter(aggregated_status=value) + + def filter_status_in(self, queryset, name, value): + values = value + if isinstance(value, str): + values = [part.strip() for part in value.split(",") if part.strip()] + + allowed = {choice[0] for choice in self.STATUS_CHOICES} + invalid = [ + status_value for status_value in values if status_value not in allowed + ] + if invalid: + raise ValidationError( + [ + { + "detail": f"invalid status filter: {invalid[0]}", + "status": "400", + "source": {"pointer": "/data"}, + "code": "invalid", + } + ] + ) + + if not values: + return queryset + + return queryset.filter(aggregated_status__in=values) + + def filter_severity(self, queryset, name, value): + severity_order = SEVERITY_ORDER.get(value) + if severity_order is None: + raise ValidationError( + [ + { + "detail": f"invalid severity filter: {value}", + "status": "400", + "source": {"pointer": "/data"}, + "code": "invalid", + } + ] + ) + return queryset.filter(severity_order=severity_order) + + def filter_severity_in(self, queryset, name, value): + values = value + if isinstance(value, str): + values = [part.strip() for part in value.split(",") if part.strip()] + + orders = [] + for severity_value in values: + severity_order = SEVERITY_ORDER.get(severity_value) + if severity_order is None: + raise ValidationError( + [ + { + "detail": f"invalid severity filter: {severity_value}", + "status": "400", + "source": {"pointer": "/data"}, + "code": "invalid", + } + ] + ) + orders.append(severity_order) + + if not orders: + return queryset + + return queryset.filter(severity_order__in=orders) + + def filter_include_muted(self, queryset, name, value): + if value is True: + return queryset + # include_muted=false: exclude fully-muted groups + return queryset.exclude(fail_count=0, pass_count=0, muted_count__gt=0) + + class ProviderSecretFilter(FilterSet): inserted_at = DateFilter( field_name="inserted_at", diff --git a/api/src/backend/api/migrations/0084_googleworkspace_provider.py b/api/src/backend/api/migrations/0084_googleworkspace_provider.py new file mode 100644 index 0000000000..eb704bb6b5 --- /dev/null +++ b/api/src/backend/api/migrations/0084_googleworkspace_provider.py @@ -0,0 +1,39 @@ +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0083_image_provider"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("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"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'googleworkspace';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/migrations/0085_finding_group_daily_summary_trgm_indexes.py b/api/src/backend/api/migrations/0085_finding_group_daily_summary_trgm_indexes.py new file mode 100644 index 0000000000..f6511184cd --- /dev/null +++ b/api/src/backend/api/migrations/0085_finding_group_daily_summary_trgm_indexes.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.15 on 2026-03-18 + +from django.contrib.postgres.indexes import GinIndex, OpClass +from django.contrib.postgres.operations import AddIndexConcurrently +from django.db import migrations +from django.db.models.functions import Upper + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0084_googleworkspace_provider"), + ] + + operations = [ + AddIndexConcurrently( + model_name="findinggroupdailysummary", + index=GinIndex( + OpClass(Upper("check_id"), name="gin_trgm_ops"), + name="fgds_check_id_trgm_idx", + ), + ), + AddIndexConcurrently( + model_name="findinggroupdailysummary", + index=GinIndex( + OpClass(Upper("check_title"), name="gin_trgm_ops"), + name="fgds_check_title_trgm_idx", + ), + ), + ] diff --git a/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py b/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py new file mode 100644 index 0000000000..1c550f283a --- /dev/null +++ b/api/src/backend/api/migrations/0086_attack_paths_cleanup_periodic_task.py @@ -0,0 +1,49 @@ +from django.db import migrations + + +TASK_NAME = "attack-paths-cleanup-stale-scans" +INTERVAL_HOURS = 1 + + +def create_periodic_task(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + schedule, _ = IntervalSchedule.objects.get_or_create( + every=INTERVAL_HOURS, + period="hours", + ) + + PeriodicTask.objects.update_or_create( + name=TASK_NAME, + defaults={ + "task": TASK_NAME, + "interval": schedule, + "enabled": True, + }, + ) + + +def delete_periodic_task(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + + PeriodicTask.objects.filter(name=TASK_NAME).delete() + + # Clean up the schedule if no other task references it + IntervalSchedule.objects.filter( + every=INTERVAL_HOURS, + period="hours", + periodictask__isnull=True, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0085_finding_group_daily_summary_trgm_indexes"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.RunPython(create_periodic_task, delete_periodic_task), + ] diff --git a/api/src/backend/api/migrations/0087_vercel_provider.py b/api/src/backend/api/migrations/0087_vercel_provider.py new file mode 100644 index 0000000000..84a07b3194 --- /dev/null +++ b/api/src/backend/api/migrations/0087_vercel_provider.py @@ -0,0 +1,40 @@ +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0086_attack_paths_cleanup_periodic_task"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("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"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'vercel';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 1f82c4a7da..5e0880be08 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1,14 +1,15 @@ import json import logging import re -import xml.etree.ElementTree as ET from datetime import datetime, timedelta, timezone from uuid import UUID, uuid4 +import defusedxml from allauth.socialaccount.models import SocialApp from config.custom_logging import BackendLogger from config.settings.social_login import SOCIALACCOUNT_PROVIDERS from cryptography.fernet import Fernet, InvalidToken +from defusedxml import ElementTree as ET from django.conf import settings from django.contrib.auth.models import AbstractBaseUser from django.contrib.postgres.fields import ArrayField @@ -293,6 +294,8 @@ class Provider(RowLevelSecurityProtectedModel): CLOUDFLARE = "cloudflare", _("Cloudflare") OPENSTACK = "openstack", _("OpenStack") IMAGE = "image", _("Image") + GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace") + VERCEL = "vercel", _("Vercel") @staticmethod def validate_aws_uid(value): @@ -342,6 +345,15 @@ class Provider(RowLevelSecurityProtectedModel): pointer="/data/attributes/uid", ) + @staticmethod + def validate_googleworkspace_uid(value): + if not re.match(r"^C[0-9a-zA-Z]+$", value): + raise ModelValidationError( + detail="Google Workspace Customer ID must start with 'C' followed by one or more alphanumeric characters (e.g., C01234abc, C12345678).", + code="googleworkspace-uid", + pointer="/data/attributes/uid", + ) + @staticmethod def validate_kubernetes_uid(value): if not re.match( @@ -427,6 +439,15 @@ class Provider(RowLevelSecurityProtectedModel): pointer="/data/attributes/uid", ) + @staticmethod + def validate_vercel_uid(value): + if not re.match(r"^team_[a-zA-Z0-9]{16,32}$", value): + raise ModelValidationError( + detail="Vercel provider ID must be a valid Vercel Team ID (e.g., team_xxxxxxxxxxxxxxxxxxxxxxxx).", + code="vercel-uid", + pointer="/data/attributes/uid", + ) + @staticmethod def validate_image_uid(value): if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{2,249}$", value): @@ -1773,6 +1794,15 @@ class FindingGroupDailySummary(RowLevelSecurityProtectedModel): fields=["tenant_id", "provider", "inserted_at"], name="fgds_tenant_prov_ins_idx", ), + # Trigram indexes for case-insensitive search + GinIndex( + OpClass(Upper("check_id"), name="gin_trgm_ops"), + name="fgds_check_id_trgm_idx", + ), + GinIndex( + OpClass(Upper("check_title"), name="gin_trgm_ops"), + name="fgds_check_title_trgm_idx", + ), ] class JSONAPIMeta: @@ -2048,6 +2078,8 @@ class SAMLConfiguration(RowLevelSecurityProtectedModel): root = ET.fromstring(self.metadata_xml) except ET.ParseError as e: raise ValidationError({"metadata_xml": f"Invalid XML: {e}"}) + except defusedxml.DefusedXmlException as e: + raise ValidationError({"metadata_xml": f"Unsafe XML content rejected: {e}"}) # Entity ID entity_id = root.attrib.get("entityID") diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index 97d7d785e0..cfbabf6c0b 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -1,7 +1,7 @@ from enum import Enum -from typing import Optional from django.db.models import QuerySet +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission from api.db_router import MainRouter @@ -29,8 +29,17 @@ class HasPermissions(BasePermission): if not required_permissions: return True + tenant_id = getattr(request, "tenant_id", None) + if not tenant_id: + tenant_id = request.auth.get("tenant_id") if request.auth else None + if not tenant_id: + return False + user_roles = ( - User.objects.using(MainRouter.admin_db).get(id=request.user.id).roles.all() + User.objects.using(MainRouter.admin_db) + .get(id=request.user.id) + .roles.using(MainRouter.admin_db) + .filter(tenant_id=tenant_id) ) if not user_roles: return False @@ -42,14 +51,17 @@ class HasPermissions(BasePermission): return True -def get_role(user: User) -> Optional[Role]: +def get_role(user: User, tenant_id: str) -> Role: """ - Retrieve the first role assigned to the given user. + Retrieve the role assigned to the given user in the specified tenant. - Returns: - The user's first Role instance if the user has any roles, otherwise None. + Raises: + PermissionDenied: If the user has no role in the given tenant. """ - return user.roles.first() + role = user.roles.using(MainRouter.admin_db).filter(tenant_id=tenant_id).first() + if role is None: + raise PermissionDenied("User has no role in this tenant.") + return role def get_providers(role: Role) -> QuerySet[Provider]: diff --git a/api/src/backend/api/signals.py b/api/src/backend/api/signals.py index d449144bf4..7bca0da0a6 100644 --- a/api/src/backend/api/signals.py +++ b/api/src/backend/api/signals.py @@ -61,7 +61,7 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841 in that tenant should be revoked to prevent further access. """ TenantAPIKey.objects.filter( - entity=instance.user, tenant_id=instance.tenant.id + entity_id=instance.user_id, tenant_id=instance.tenant_id ).update(revoked=True) diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index b884e00f54..7819f8010c 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.20.0 + version: 1.24.0 description: |- Prowler API specification. @@ -356,7 +356,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -364,6 +364,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -371,6 +372,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -385,13 +387,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -399,6 +403,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -406,6 +411,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -422,6 +428,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -723,7 +731,7 @@ paths: $ref: '#/components/schemas/OpenApiResponseResponse' text/plain: schema: - type: string + $ref: '#/components/schemas/AttackPathsQueryResult' description: '' '403': description: Read-only queries are enforced @@ -769,7 +777,7 @@ paths: $ref: '#/components/schemas/OpenApiResponseResponse' text/plain: schema: - type: string + $ref: '#/components/schemas/AttackPathsQueryResult' description: '' '400': description: Bad request (e.g., Unknown Attack Paths query for the selected @@ -1331,7 +1339,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -1339,6 +1347,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -1346,6 +1355,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -1360,6 +1370,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: @@ -1805,7 +1817,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -1813,6 +1825,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -1820,6 +1833,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -1834,13 +1848,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -1848,6 +1864,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -1855,6 +1872,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -1871,6 +1889,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -2403,7 +2423,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -2411,6 +2431,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -2418,6 +2439,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -2432,13 +2454,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -2446,6 +2470,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -2453,6 +2478,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -2469,6 +2495,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -2909,7 +2937,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -2917,6 +2945,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -2924,6 +2953,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -2938,13 +2968,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -2952,6 +2984,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -2959,6 +2992,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -2975,6 +3009,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -3413,7 +3449,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -3421,6 +3457,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -3428,6 +3465,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -3442,13 +3480,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -3456,6 +3496,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -3463,6 +3504,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -3479,6 +3521,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -3905,7 +3949,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -3913,6 +3957,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -3920,6 +3965,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -3934,13 +3980,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -3948,6 +3996,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -3955,6 +4004,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -3971,6 +4021,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -5738,7 +5790,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -5746,6 +5798,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -5753,6 +5806,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -5767,13 +5821,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -5781,6 +5837,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -5788,6 +5845,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -5804,6 +5862,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - name: filter[search] @@ -5909,7 +5969,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -5917,6 +5977,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -5924,6 +5985,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -5938,13 +6000,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -5952,6 +6016,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -5959,6 +6024,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -5975,6 +6041,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - name: filter[search] @@ -6077,6 +6145,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6084,6 +6153,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -6098,6 +6168,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: @@ -6111,6 +6183,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6118,6 +6191,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -6134,6 +6208,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - name: filter[search] @@ -6260,7 +6336,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -6268,6 +6344,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6275,6 +6352,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -6289,13 +6367,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -6303,6 +6383,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6310,6 +6391,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -6326,6 +6408,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -6465,7 +6549,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -6473,6 +6557,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6480,6 +6565,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -6494,13 +6580,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -6508,6 +6596,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6515,6 +6604,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -6531,6 +6621,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -6672,6 +6764,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6679,6 +6772,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -6693,6 +6787,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: @@ -6706,6 +6802,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6713,6 +6810,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -6729,6 +6827,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - name: filter[search] @@ -6904,7 +7004,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -6912,6 +7012,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6919,6 +7020,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -6933,13 +7035,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -6947,6 +7051,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -6954,6 +7059,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -6970,6 +7076,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -7074,7 +7182,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -7082,6 +7190,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -7089,6 +7198,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -7103,13 +7213,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -7117,6 +7229,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -7124,6 +7237,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -7140,6 +7254,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -7268,7 +7384,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -7276,6 +7392,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -7283,6 +7400,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -7297,13 +7415,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -7311,6 +7431,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -7318,6 +7439,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -7334,6 +7456,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -8103,7 +8227,7 @@ paths: name: filter[provider] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -8111,6 +8235,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -8118,6 +8243,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -8132,13 +8258,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -8146,6 +8274,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -8153,6 +8282,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -8169,13 +8299,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -8183,6 +8315,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -8190,6 +8323,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -8204,13 +8338,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -8218,6 +8354,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -8225,6 +8362,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -8241,6 +8379,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - name: filter[search] @@ -8894,7 +9034,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -8902,6 +9042,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -8909,6 +9050,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -8923,13 +9065,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -8937,6 +9081,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -8944,6 +9089,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -8960,6 +9106,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -9437,7 +9585,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -9445,6 +9593,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -9452,6 +9601,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -9466,13 +9616,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -9480,6 +9632,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -9487,6 +9640,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -9503,6 +9657,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -9793,7 +9949,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -9801,6 +9957,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -9808,6 +9965,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -9822,13 +9980,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -9836,6 +9996,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -9843,6 +10004,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -9859,6 +10021,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -10155,7 +10319,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -10163,6 +10327,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -10170,6 +10335,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -10184,13 +10350,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -10198,6 +10366,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -10205,6 +10374,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -10221,6 +10391,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -11027,7 +11199,7 @@ paths: name: filter[provider_type] schema: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -11035,6 +11207,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -11042,6 +11215,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- * `aws` - AWS * `azure` - Azure @@ -11056,13 +11230,15 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel - in: query name: filter[provider_type__in] schema: type: array items: type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 enum: - alibabacloud - aws @@ -11070,6 +11246,7 @@ paths: - cloudflare - gcp - github + - googleworkspace - iac - image - kubernetes @@ -11077,6 +11254,7 @@ paths: - mongodbatlas - openstack - oraclecloud + - vercel description: |- Multiple values may be separated by commas. @@ -11093,6 +11271,8 @@ paths: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel explode: false style: form - in: query @@ -18189,6 +18369,22 @@ components: description: The service account key for GCP. required: - service_account_key + - type: object + title: Google Workspace Service Account + properties: + credentials_content: + type: string + description: The service account JSON credentials content + for Google Workspace API access with domain-wide delegation + enabled. + delegated_user: + type: string + format: email + description: The email address of the Google Workspace super + admin user to impersonate for domain-wide delegation. + required: + - credentials_content + - delegated_user - type: object title: Kubernetes Static Credentials properties: @@ -18357,6 +18553,15 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Vercel API Token + properties: + api_token: + type: string + description: Vercel API token for authentication. Can be scoped + to a specific team. + required: + - api_token writeOnly: true required: - secret @@ -19358,6 +19563,8 @@ components: - cloudflare - openstack - image + - googleworkspace + - vercel type: string description: |- * `aws` - AWS @@ -19373,7 +19580,9 @@ components: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image - x-spec-enum-id: 7ae539a3796eb30a + * `googleworkspace` - Google Workspace + * `vercel` - Vercel + x-spec-enum-id: c0d56cad8ab9abe5 uid: type: string title: Unique identifier for the provider, set by the provider @@ -19492,8 +19701,10 @@ components: - cloudflare - openstack - image + - googleworkspace + - vercel type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 description: |- Type of provider to create. @@ -19510,6 +19721,8 @@ components: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel uid: type: string title: Unique identifier for the provider, set by the provider @@ -19560,8 +19773,10 @@ components: - cloudflare - openstack - image + - googleworkspace + - vercel type: string - x-spec-enum-id: 7ae539a3796eb30a + x-spec-enum-id: c0d56cad8ab9abe5 description: |- Type of provider to create. @@ -19578,6 +19793,8 @@ components: * `cloudflare` - Cloudflare * `openstack` - OpenStack * `image` - Image + * `googleworkspace` - Google Workspace + * `vercel` - Vercel uid: type: string minLength: 3 @@ -20249,6 +20466,21 @@ components: description: The service account key for GCP. required: - service_account_key + - type: object + title: Google Workspace Service Account + properties: + credentials_content: + type: string + description: The service account JSON credentials content for + Google Workspace API access with domain-wide delegation enabled. + delegated_user: + type: string + format: email + description: The email address of the Google Workspace super admin + user to impersonate for domain-wide delegation. + required: + - credentials_content + - delegated_user - type: object title: Kubernetes Static Credentials properties: @@ -20412,6 +20644,15 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Vercel API Token + properties: + api_token: + type: string + description: Vercel API token for authentication. Can be scoped + to a specific team. + required: + - api_token writeOnly: true required: - secret_type @@ -20644,6 +20885,22 @@ components: description: The service account key for GCP. required: - service_account_key + - type: object + title: Google Workspace Service Account + properties: + credentials_content: + type: string + description: The service account JSON credentials content + for Google Workspace API access with domain-wide delegation + enabled. + delegated_user: + type: string + format: email + description: The email address of the Google Workspace super + admin user to impersonate for domain-wide delegation. + required: + - credentials_content + - delegated_user - type: object title: Kubernetes Static Credentials properties: @@ -20812,6 +21069,15 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Vercel API Token + properties: + api_token: + type: string + description: Vercel API token for authentication. Can be scoped + to a specific team. + required: + - api_token writeOnly: true required: - secret_type @@ -21060,6 +21326,21 @@ components: description: The service account key for GCP. required: - service_account_key + - type: object + title: Google Workspace Service Account + properties: + credentials_content: + type: string + description: The service account JSON credentials content for + Google Workspace API access with domain-wide delegation enabled. + delegated_user: + type: string + format: email + description: The email address of the Google Workspace super admin + user to impersonate for domain-wide delegation. + required: + - credentials_content + - delegated_user - type: object title: Kubernetes Static Credentials properties: @@ -21223,6 +21504,15 @@ components: required: - clouds_yaml_content - clouds_yaml_cloud + - type: object + title: Vercel API Token + properties: + api_token: + type: string + description: Vercel API token for authentication. Can be scoped + to a specific team. + required: + - api_token writeOnly: true required: - secret diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index 2abf725124..061c2efac0 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -215,6 +215,21 @@ class TestTokenSwitchTenant: tenant_id = tenants_fixture[0].id user_instance = User.objects.get(email=test_user) Membership.objects.create(user=user_instance, tenant_id=tenant_id) + # Assign an admin role in the target tenant so the user can access resources + target_role = Role.objects.create( + name="admin", + tenant_id=tenant_id, + manage_users=True, + manage_account=True, + manage_billing=True, + manage_providers=True, + manage_integrations=True, + manage_scans=True, + unlimited_visibility=True, + ) + UserRoleRelationship.objects.create( + user=user_instance, role=target_role, tenant_id=tenant_id + ) # Check that using our new user's credentials we can authenticate and get the providers access_token, _ = get_api_tokens(client, test_user, test_password) @@ -301,7 +316,7 @@ class TestTokenSwitchTenant: assert invalid_tenant_response.status_code == 400 assert invalid_tenant_response.json()["errors"][0]["code"] == "invalid" assert invalid_tenant_response.json()["errors"][0]["detail"] == ( - "Tenant does not exist or user is not a " "member." + "Tenant does not exist or user is not a member." ) @@ -912,10 +927,9 @@ class TestAPIKeyLifecycle: auth_response = client.get(reverse("provider-list"), headers=api_key_headers) # Must return 401 Unauthorized, not 500 Internal Server Error - assert auth_response.status_code == 401, ( - f"Expected 401 but got {auth_response.status_code}: " - f"{auth_response.json()}" - ) + assert ( + auth_response.status_code == 401 + ), f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}" # Verify error message is present response_json = auth_response.json() diff --git a/api/src/backend/api/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 712bc33882..5889b4e2cb 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -10,11 +10,11 @@ from django.conf import settings import api import api.apps as api_apps_module from api.apps import ( - ApiConfig, PRIVATE_KEY_FILE, PUBLIC_KEY_FILE, SIGNING_KEY_ENV, VERIFYING_KEY_ENV, + ApiConfig, ) @@ -187,9 +187,10 @@ def test_ready_initializes_driver_for_api_process(monkeypatch): _set_argv(monkeypatch, ["gunicorn"]) _set_testing(monkeypatch, False) - with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( - "api.attack_paths.database.init_driver" - ) as init_driver: + with ( + patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), + patch("api.attack_paths.database.init_driver") as init_driver, + ): config.ready() init_driver.assert_called_once() @@ -200,9 +201,10 @@ def test_ready_skips_driver_for_celery(monkeypatch): _set_argv(monkeypatch, ["celery", "-A", "api"]) _set_testing(monkeypatch, False) - with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( - "api.attack_paths.database.init_driver" - ) as init_driver: + with ( + patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), + patch("api.attack_paths.database.init_driver") as init_driver, + ): config.ready() init_driver.assert_not_called() @@ -213,9 +215,10 @@ def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch): _set_argv(monkeypatch, ["manage.py", "migrate"]) _set_testing(monkeypatch, False) - with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( - "api.attack_paths.database.init_driver" - ) as init_driver: + with ( + patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), + patch("api.attack_paths.database.init_driver") as init_driver, + ): config.ready() init_driver.assert_not_called() @@ -226,9 +229,10 @@ def test_ready_skips_driver_when_testing(monkeypatch): _set_argv(monkeypatch, ["gunicorn"]) _set_testing(monkeypatch, True) - with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch( - "api.attack_paths.database.init_driver" - ) as init_driver: + with ( + patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), + patch("api.attack_paths.database.init_driver") as init_driver, + ): config.ready() init_driver.assert_not_called() diff --git a/api/src/backend/api/tests/test_attack_paths.py b/api/src/backend/api/tests/test_attack_paths.py index 097a735464..019b6aa1f2 100644 --- a/api/src/backend/api/tests/test_attack_paths.py +++ b/api/src/backend/api/tests/test_attack_paths.py @@ -9,6 +9,10 @@ from rest_framework.exceptions import APIException, PermissionDenied, Validation from api.attack_paths import database as graph_database from api.attack_paths import views_helpers +from tasks.jobs.attack_paths.config import ( + PROVIDER_ELEMENT_ID_PROPERTY, + get_provider_label, +) def _make_neo4j_error(message, code): @@ -49,7 +53,7 @@ def test_prepare_parameters_includes_provider_and_casts( ) assert result["provider_uid"] == "123456789012" - assert result["provider_id"] == "test-provider-id" + assert "provider_id" not in result assert result["limit"] == 5 @@ -103,12 +107,12 @@ def test_execute_query_serializes_graph( parameters = {"provider_uid": "123"} provider_id = "test-provider-123" + plabel = get_provider_label(provider_id) node = attack_paths_graph_stub_classes.Node( element_id="node-1", - labels=["AWSAccount"], + labels=["AWSAccount", plabel], properties={ "name": "account", - "provider_id": provider_id, "complex": { "items": [ attack_paths_graph_stub_classes.NativeValue("value"), @@ -117,15 +121,13 @@ def test_execute_query_serializes_graph( }, }, ) - node_2 = attack_paths_graph_stub_classes.Node( - "node-2", ["RDSInstance"], {"provider_id": provider_id} - ) + node_2 = attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance", plabel], {}) relationship = attack_paths_graph_stub_classes.Relationship( element_id="rel-1", rel_type="OWNS", start_node=node, end_node=node_2, - properties={"weight": 1, "provider_id": provider_id}, + properties={"weight": 1}, ) graph = SimpleNamespace(nodes=[node, node_2], relationships=[relationship]) @@ -209,29 +211,27 @@ def test_execute_query_raises_permission_denied_on_read_only( ) -def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes): +def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_classes): provider_id = "provider-keep" + plabel = get_provider_label(provider_id) + other_label = get_provider_label("provider-other") - node_keep = attack_paths_graph_stub_classes.Node( - "n1", ["AWSAccount"], {"provider_id": provider_id} - ) + node_keep = attack_paths_graph_stub_classes.Node("n1", ["AWSAccount", plabel], {}) node_drop = attack_paths_graph_stub_classes.Node( - "n2", ["AWSAccount"], {"provider_id": "provider-other"} + "n2", ["AWSAccount", other_label], {} ) rel_keep = attack_paths_graph_stub_classes.Relationship( - "r1", "OWNS", node_keep, node_keep, {"provider_id": provider_id} - ) - rel_drop_by_provider = attack_paths_graph_stub_classes.Relationship( - "r2", "OWNS", node_keep, node_drop, {"provider_id": "provider-other"} + "r1", "OWNS", node_keep, node_keep, {} ) + # Relationship connecting a kept node to a dropped node — filtered by endpoint check rel_drop_orphaned = attack_paths_graph_stub_classes.Relationship( - "r3", "OWNS", node_keep, node_drop, {"provider_id": provider_id} + "r2", "OWNS", node_keep, node_drop, {} ) graph = SimpleNamespace( nodes=[node_keep, node_drop], - relationships=[rel_keep, rel_drop_by_provider, rel_drop_orphaned], + relationships=[rel_keep, rel_drop_orphaned], ) result = views_helpers._serialize_graph(graph, provider_id) @@ -350,10 +350,7 @@ def test_serialize_properties_filters_internal_fields(): "_module_name": "cartography:aws", "_module_version": "0.98.0", # Provider isolation - "_provider_id": "42", - "_provider_element_id": "42:abc123", - "provider_id": "42", - "provider_element_id": "42:abc123", + PROVIDER_ELEMENT_ID_PROPERTY: "42:abc123", } result = views_helpers._serialize_properties(properties) @@ -361,6 +358,14 @@ def test_serialize_properties_filters_internal_fields(): assert result == {"name": "prod"} +def test_filter_labels_strips_dynamic_isolation_labels(): + labels = ["AWSRole", "_Tenant_abc123", "_Provider_def456", "_ProviderResource"] + + result = views_helpers._filter_labels(labels) + + assert result == ["AWSRole"] + + def test_serialize_graph_as_text_node_without_properties(): graph = { "nodes": [{"id": "n1", "labels": ["AWSAccount"], "properties": {}}], @@ -439,14 +444,11 @@ def test_execute_custom_query_serializes_graph( attack_paths_graph_stub_classes, ): provider_id = "test-provider-123" - node_1 = attack_paths_graph_stub_classes.Node( - "node-1", ["AWSAccount"], {"provider_id": provider_id} - ) - node_2 = attack_paths_graph_stub_classes.Node( - "node-2", ["RDSInstance"], {"provider_id": provider_id} - ) + plabel = get_provider_label(provider_id) + node_1 = attack_paths_graph_stub_classes.Node("node-1", ["AWSAccount", plabel], {}) + node_2 = attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance", plabel], {}) relationship = attack_paths_graph_stub_classes.Relationship( - "rel-1", "OWNS", node_1, node_2, {"provider_id": provider_id} + "rel-1", "OWNS", node_1, node_2, {} ) graph_result = MagicMock() @@ -461,10 +463,11 @@ def test_execute_custom_query_serializes_graph( "db-tenant-test", "MATCH (n) RETURN n", provider_id ) - mock_execute.assert_called_once_with( - database="db-tenant-test", - cypher="MATCH (n) RETURN n", - ) + mock_execute.assert_called_once() + call_kwargs = mock_execute.call_args[1] + assert call_kwargs["database"] == "db-tenant-test" + # The cypher is rewritten with the provider label injection + assert plabel in call_kwargs["cypher"] assert len(result["nodes"]) == 2 assert result["relationships"][0]["label"] == "OWNS" assert result["truncated"] is False 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 8b458cb7b7..7e07792a69 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -442,3 +442,78 @@ class TestThreadSafety: # 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): + import api.attack_paths.database as db_module + + 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, + ): + 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): + import api.attack_paths.database as db_module + + 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): + import api.attack_paths.database as db_module + + 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 + ) + + def test_raises_on_other_errors(self): + import api.attack_paths.database as db_module + + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Connection refused", + code="Neo.TransientError.General.UnknownError", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + with pytest.raises(db_module.GraphDatabaseQueryException): + db_module.has_provider_data("db-tenant-abc", "provider-123") diff --git a/api/src/backend/api/tests/test_celery_settings.py b/api/src/backend/api/tests/test_celery_settings.py new file mode 100644 index 0000000000..d4010796ee --- /dev/null +++ b/api/src/backend/api/tests/test_celery_settings.py @@ -0,0 +1,43 @@ +import pytest +from config.settings.celery import _build_celery_broker_url + + +class TestBuildCeleryBrokerUrl: + def test_without_credentials(self): + broker_url = _build_celery_broker_url("redis", "", "", "valkey", "6379", "0") + + assert broker_url == "redis://valkey:6379/0" + + def test_with_password_only(self): + broker_url = _build_celery_broker_url( + "rediss", "", "secret", "cache.example.com", "6379", "0" + ) + + assert broker_url == "rediss://:secret@cache.example.com:6379/0" + + def test_with_username_and_password(self): + broker_url = _build_celery_broker_url( + "rediss", "default", "secret", "cache.example.com", "6379", "0" + ) + + assert broker_url == "rediss://default:secret@cache.example.com:6379/0" + + def test_with_username_only(self): + broker_url = _build_celery_broker_url( + "redis", "admin", "", "valkey", "6379", "0" + ) + + assert broker_url == "redis://admin@valkey:6379/0" + + def test_url_encodes_credentials(self): + broker_url = _build_celery_broker_url( + "rediss", "user@name", "p@ss:word", "cache.example.com", "6379", "0" + ) + + assert ( + broker_url == "rediss://user%40name:p%40ss%3Aword@cache.example.com:6379/0" + ) + + def test_invalid_scheme_raises_error(self): + with pytest.raises(ValueError, match="Invalid VALKEY_SCHEME 'http'"): + _build_celery_broker_url("http", "", "", "valkey", "6379", "0") diff --git a/api/src/backend/api/tests/test_cypher_sanitizer.py b/api/src/backend/api/tests/test_cypher_sanitizer.py new file mode 100644 index 0000000000..a54afcd8bb --- /dev/null +++ b/api/src/backend/api/tests/test_cypher_sanitizer.py @@ -0,0 +1,429 @@ +"""Unit tests for the Cypher sanitizer (validation + provider-label injection).""" + +from unittest.mock import patch + +import pytest + +from rest_framework.exceptions import ValidationError + +from api.attack_paths.cypher_sanitizer import ( + inject_provider_label, + validate_custom_query, +) + +PROVIDER_ID = "019c41ee-7df3-7dec-a684-d839f95619f8" +LABEL = "_Provider_019c41ee7df37deca684d839f95619f8" + + +def _inject(cypher: str) -> str: + """Shortcut that patches `get_provider_label` to avoid config imports.""" + with patch( + "api.attack_paths.cypher_sanitizer.get_provider_label", return_value=LABEL + ): + return inject_provider_label(cypher, PROVIDER_ID) + + +# --------------------------------------------------------------------------- +# Pass A - Labeled node patterns (all clauses) +# --------------------------------------------------------------------------- + + +class TestLabeledNodes: + def test_single_label(self): + result = _inject("MATCH (n:AWSRole) RETURN n") + assert f"(n:AWSRole:{LABEL})" in result + + def test_label_with_properties(self): + result = _inject("MATCH (n:AWSRole {name: 'admin'}) RETURN n") + assert f"(n:AWSRole:{LABEL} {{name: 'admin'}})" in result + + def test_multiple_labels(self): + result = _inject("MATCH (n:AWSRole:AWSPrincipal) RETURN n") + assert f"(n:AWSRole:AWSPrincipal:{LABEL})" in result + + def test_anonymous_labeled(self): + result = _inject( + "MATCH (:AWSPrincipal {arn: 'ecs-tasks.amazonaws.com'}) RETURN 1" + ) + assert f"(:AWSPrincipal:{LABEL} {{arn: 'ecs-tasks.amazonaws.com'}})" in result + + def test_backtick_label(self): + result = _inject("MATCH (n:`My Label`) RETURN n") + assert f"(n:`My Label`:{LABEL})" in result + + def test_labeled_in_where_clause(self): + """Labeled nodes in WHERE (pattern existence) still get the label.""" + result = _inject( + "MATCH (n:AWSRole) WHERE EXISTS((n)-[:REL]->(:Target)) RETURN n" + ) + assert f"(n:AWSRole:{LABEL})" in result + assert f"(:Target:{LABEL})" in result + + def test_labeled_in_return_clause(self): + """Labeled nodes in RETURN still get the label (they're always node patterns).""" + result = _inject("MATCH (n:AWSRole) RETURN (n:AWSRole)") + assert result.count(f":AWSRole:{LABEL}") == 2 + + def test_labeled_in_optional_match(self): + result = _inject( + "OPTIONAL MATCH (pf:ProwlerFinding {status: 'FAIL'}) RETURN pf" + ) + assert f"(pf:ProwlerFinding:{LABEL} {{status: 'FAIL'}})" in result + + +# --------------------------------------------------------------------------- +# Pass B - Bare node patterns (MATCH/OPTIONAL MATCH only) +# --------------------------------------------------------------------------- + + +class TestBareNodes: + def test_bare_in_match(self): + result = _inject("MATCH (a)-[:HAS_POLICY]->(b) RETURN a, b") + assert f"(a:{LABEL})" in result + assert f"(b:{LABEL})" in result + + def test_bare_with_properties_in_match(self): + result = _inject("MATCH (n {name: 'x'}) RETURN n") + assert f"(n:{LABEL} {{name: 'x'}})" in result + + def test_bare_in_optional_match(self): + result = _inject("OPTIONAL MATCH (n)-[r]-(m) RETURN n") + assert f"(n:{LABEL})" in result + assert f"(m:{LABEL})" in result + + def test_bare_not_injected_in_return(self): + """Bare (identifier) in RETURN could be expression grouping.""" + cypher = "MATCH (n:AWSRole) RETURN (n)" + result = _inject(cypher) + # The labeled (n:AWSRole) gets the label, but the bare (n) in RETURN should not + assert f"(n:AWSRole:{LABEL})" in result + # Count how many times the label appears - should be 1 (from MATCH only) + assert result.count(LABEL) == 1 + + def test_bare_not_injected_in_where(self): + cypher = "MATCH (n:AWSRole) WHERE (n.x > 1) RETURN n" + result = _inject(cypher) + # (n.x > 1) is an expression group, not a node pattern - should be untouched + assert "(n.x > 1)" in result + + def test_bare_not_injected_in_with(self): + cypher = "MATCH (n:AWSRole) WITH (n) RETURN n" + result = _inject(cypher) + assert result.count(LABEL) == 1 + + def test_bare_not_injected_in_unwind(self): + cypher = "UNWIND nodes(path) as n OPTIONAL MATCH (n)-[r]-(m) RETURN n" + result = _inject(cypher) + # (n) and (m) in OPTIONAL MATCH get injected, but nodes(path) in UNWIND does not + assert f"(n:{LABEL})" in result + assert f"(m:{LABEL})" in result + + +# --------------------------------------------------------------------------- +# Function call exclusion +# --------------------------------------------------------------------------- + + +class TestFunctionCallExclusion: + @pytest.mark.parametrize( + "func_call", + [ + "collect(DISTINCT pf)", + "any(x IN stmt.action WHERE toLower(x) = 'iam:*')", + "toLower(action)", + "nodes(path)", + "count(n)", + "apoc.create.vNode(labels)", + "EXISTS(n.prop)", + "size(n.list)", + ], + ) + def test_function_calls_not_injected(self, func_call): + cypher = f"MATCH (n:AWSRole) WHERE {func_call} RETURN n" + result = _inject(cypher) + # The function call should remain unchanged + assert func_call in result + # Only the MATCH labeled node should get the label + assert result.count(LABEL) == 1 + + +# --------------------------------------------------------------------------- +# String and comment protection +# --------------------------------------------------------------------------- + + +class TestProtection: + def test_string_with_fake_node_pattern(self): + cypher = "MATCH (n:AWSRole) WHERE n.name = '(fake:Label)' RETURN n" + result = _inject(cypher) + assert "'(fake:Label)'" in result + assert result.count(LABEL) == 1 + + def test_double_quoted_string(self): + cypher = 'MATCH (n:AWSRole) WHERE n.name = "(fake:Label)" RETURN n' + result = _inject(cypher) + assert '"(fake:Label)"' in result + assert result.count(LABEL) == 1 + + def test_line_comment_with_node_pattern(self): + cypher = "// (n:Fake)\nMATCH (n:AWSRole) RETURN n" + result = _inject(cypher) + assert "// (n:Fake)" in result + assert result.count(LABEL) == 1 + + def test_string_containing_double_slash(self): + """Strings with // inside should be consumed as strings, not comments.""" + cypher = "MATCH (n:AWSRole {url: 'https://example.com'}) RETURN n" + result = _inject(cypher) + assert "'https://example.com'" in result + assert f"(n:AWSRole:{LABEL}" in result + + def test_escaped_quotes_in_string(self): + cypher = r"MATCH (n:AWSRole) WHERE n.name = 'it\'s a test' RETURN n" + result = _inject(cypher) + assert result.count(LABEL) == 1 + + +# --------------------------------------------------------------------------- +# Clause splitting +# --------------------------------------------------------------------------- + + +class TestClauseSplitting: + def test_case_insensitive_keywords(self): + cypher = "match (n:AWSRole) where n.x = 1 return n" + result = _inject(cypher) + assert f"(n:AWSRole:{LABEL})" in result + + def test_optional_match_with_extra_whitespace(self): + cypher = "OPTIONAL MATCH (n:AWSRole) RETURN n" + result = _inject(cypher) + assert f"(n:AWSRole:{LABEL})" in result + + def test_multiple_match_clauses(self): + cypher = ( + "MATCH (a:AWSAccount)--(b:AWSRole) " + "MATCH (b)--(c:AWSPolicy) " + "RETURN a, b, c" + ) + result = _inject(cypher) + assert f"(a:AWSAccount:{LABEL})" in result + assert f"(b:AWSRole:{LABEL})" in result + assert f"(c:AWSPolicy:{LABEL})" in result + # (b) in second MATCH is bare and gets injected + assert result.count(LABEL) == 4 # a, b (labeled), b (bare in 2nd MATCH), c + + +# --------------------------------------------------------------------------- +# Real-world query patterns from aws.py +# --------------------------------------------------------------------------- + + +class TestRealWorldQueries: + def test_basic_resource_query(self): + cypher = ( + "MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)\n" + "UNWIND nodes(path) as n\n" + "OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding {status: 'FAIL'})\n" + "RETURN path, collect(DISTINCT pf) as dpf" + ) + result = _inject(cypher) + assert f"(aws:AWSAccount:{LABEL} {{id: $provider_uid}})" in result + assert f"(rds:RDSInstance:{LABEL})" in result + assert f"(n:{LABEL})" in result + assert f"(pf:ProwlerFinding:{LABEL} {{status: 'FAIL'}})" in result + assert "nodes(path)" in result # function call untouched + assert "collect(DISTINCT pf)" in result # function call untouched + + def test_privilege_escalation_query(self): + cypher = ( + "MATCH path_principal = (aws:AWSAccount {id: $uid})" + "--(principal:AWSPrincipal)--(pol:AWSPolicy)\n" + "WHERE pol.effect = 'Allow'\n" + "MATCH (principal)--(cfn_policy:AWSPolicy)" + "--(stmt_cfn:AWSPolicyStatement)\n" + "WHERE any(action IN stmt_cfn.action WHERE toLower(action) = 'iam:passrole')\n" + "MATCH path_target = (aws)--(target_role:AWSRole)" + "-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'cloudformation.amazonaws.com'})\n" + "RETURN path_principal, path_target" + ) + result = _inject(cypher) + assert f"(aws:AWSAccount:{LABEL} {{id: $uid}})" in result + assert f"(principal:AWSPrincipal:{LABEL})" in result + assert f"(pol:AWSPolicy:{LABEL})" in result + assert f"(principal:{LABEL})" in result # bare in 2nd MATCH + assert f"(cfn_policy:AWSPolicy:{LABEL})" in result + assert f"(stmt_cfn:AWSPolicyStatement:{LABEL})" in result + assert f"(aws:{LABEL})" in result # bare in 3rd MATCH + assert f"(target_role:AWSRole:{LABEL})" in result + assert ( + f"(:AWSPrincipal:{LABEL} {{arn: 'cloudformation.amazonaws.com'}})" in result + ) + # Function calls in WHERE untouched + assert "any(action IN" in result + assert "toLower(action)" in result + + def test_custom_bare_query(self): + cypher = ( + "MATCH (a)-[:HAS_POLICY]->(b)\n" + "WHERE a.name CONTAINS 'admin'\n" + "RETURN a, b" + ) + result = _inject(cypher) + assert f"(a:{LABEL})" in result + assert f"(b:{LABEL})" in result + assert result.count(LABEL) == 2 + + def test_internet_via_path_connectivity(self): + """Post-refactor pattern: Internet reached via CAN_ACCESS, not standalone.""" + cypher = ( + "MATCH path = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)\n" + "WHERE ec2.exposed_internet = true\n" + "OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2)\n" + "RETURN path, internet, can_access" + ) + result = _inject(cypher) + assert f"(aws:AWSAccount:{LABEL}" in result + assert f"(ec2:EC2Instance:{LABEL})" in result + assert f"(internet:Internet:{LABEL})" in result + # ec2 in OPTIONAL MATCH is bare, but already labeled via Pass A won't match it + # because it has no label. It IS bare, so Pass B injects. + assert f"(ec2:{LABEL})" in result + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_empty_query(self): + assert _inject("") == "" + + def test_no_node_patterns(self): + cypher = "RETURN 1 + 2" + assert _inject(cypher) == cypher + + def test_anonymous_empty_parens_not_injected(self): + """Empty () in MATCH is extremely rare but should not be injected.""" + cypher = "MATCH ()--(m:AWSRole) RETURN m" + result = _inject(cypher) + assert "()" in result # empty parens untouched + assert f"(m:AWSRole:{LABEL})" in result + + def test_fully_anonymous_query_bypasses_injection(self): + """All-anonymous patterns bypass injection entirely. + + MATCH ()--()--() has no labels and no variables, so neither Pass A + (labeled) nor Pass B (bare identifier) can inject the provider label. + This is safe because _serialize_graph() (Layer 3) filters every + returned node by provider label, dropping cross-provider data before + it reaches the user. + """ + cypher = "MATCH ()--()--() RETURN *" + result = _inject(cypher) + assert result == cypher # completely unmodified + assert LABEL not in result + + def test_relationship_patterns_untouched(self): + cypher = "MATCH (a:X)-[r:REL_TYPE {x: 1}]->(b:Y) RETURN a" + result = _inject(cypher) + assert "[r:REL_TYPE {x: 1}]" in result # relationship untouched + assert f"(a:X:{LABEL})" in result + assert f"(b:Y:{LABEL})" in result + + def test_call_subquery(self): + cypher = ( + "CALL {\n" + " MATCH (inner:AWSRole) RETURN inner\n" + "}\n" + "MATCH (outer:AWSAccount) RETURN outer, inner" + ) + result = _inject(cypher) + assert f"(inner:AWSRole:{LABEL})" in result + assert f"(outer:AWSAccount:{LABEL})" in result + + def test_multiple_protected_regions(self): + cypher = ( + "MATCH (n:X {a: 'hello'}) " 'WHERE n.b = "world" ' "// comment\n" "RETURN n" + ) + result = _inject(cypher) + assert "'hello'" in result + assert '"world"' in result + assert "// comment" in result + assert f"(n:X:{LABEL}" in result + + def test_idempotent_on_already_injected(self): + """Running injection twice should add the label twice (not ideal, but predictable).""" + first = _inject("MATCH (n:AWSRole) RETURN n") + second = _inject(first) + # The label appears twice (stacked) + assert second.count(LABEL) == 2 + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestValidation: + @pytest.mark.parametrize( + "cypher", + [ + "LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x", + "load csv from 'http://evil.com' as row return row", + "CALL apoc.load.json('http://evil.com/') YIELD value RETURN value", + "CALL apoc.load.csvParams('http://evil.com/', {}, null) YIELD list RETURN list", + "CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node", + "CALL apoc.export.csv.all('file.csv', {})", + "CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value", + "CALL apoc.systemdb.graph() YIELD nodes RETURN nodes", + "CALL apoc.config.list() YIELD key, value RETURN key, value", + "CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {batchSize: 100})", + "CALL apoc.do.when(true, 'CREATE (n) RETURN n', '', {}) YIELD value RETURN value", + "CALL apoc.trigger.add('t', 'RETURN 1', {phase: 'before'})", + "CALL apoc.custom.asProcedure('myProc', 'RETURN 1')", + ], + ids=[ + "LOAD_CSV", + "LOAD_CSV_lowercase", + "apoc.load.json", + "apoc.load.csvParams", + "apoc.import.csv", + "apoc.export.csv", + "apoc.cypher.run", + "apoc.systemdb.graph", + "apoc.config.list", + "apoc.periodic.iterate", + "apoc.do.when", + "apoc.trigger.add", + "apoc.custom.asProcedure", + ], + ) + def test_rejects_blocked_patterns(self, cypher): + with pytest.raises(ValidationError) as exc: + validate_custom_query(cypher) + + assert "blocked operation" in str(exc.value.detail) + + @pytest.mark.parametrize( + "cypher", + [ + "MATCH (n:AWSAccount) RETURN n LIMIT 10", + "MATCH (a)-[r]->(b) RETURN a, r, b", + "MATCH (n) WHERE n.name CONTAINS 'load' RETURN n", + "CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node", + "MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n", + 'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n', + ], + ids=[ + "simple_match", + "traversal", + "contains_load_substring", + "apoc_virtual_node", + "apoc_load_inside_single_quotes", + "load_csv_inside_double_quotes", + ], + ) + def test_allows_clean_queries(self, cypher): + validate_custom_query(cypher) diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py index f52bb349aa..18935b9a3e 100644 --- a/api/src/backend/api/tests/test_db_utils.py +++ b/api/src/backend/api/tests/test_db_utils.py @@ -6,10 +6,12 @@ import pytest from django.conf import settings from django.db import DEFAULT_DB_ALIAS, OperationalError from freezegun import freeze_time +from psycopg2 import sql as psycopg2_sql from rest_framework_json_api.serializers import ValidationError from api.db_utils import ( POSTGRES_TENANT_VAR, + PostgresEnumMigration, _should_create_index_on_partition, batch_delete, create_objects_in_batches, @@ -910,3 +912,61 @@ class TestRlsTransaction: cursor.execute("SELECT 1") result = cursor.fetchone() assert result[0] == 1 + + +class TestPostgresEnumMigration: + """ + Verify that PostgresEnumMigration builds DDL statements via psycopg2.sql + so that enum type names and values are always properly quoted — preventing + SQL injection through f-string interpolation. + """ + + def _make_mock_schema_editor(self): + mock_cursor = MagicMock() + mock_conn = MagicMock() + mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor) + mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False) + mock_schema_editor = MagicMock() + mock_schema_editor.connection = mock_conn + return mock_schema_editor, mock_cursor + + def test_create_enum_type_generates_correct_sql(self): + """create_enum_type builds a proper CREATE TYPE … AS ENUM via psycopg2.sql.""" + migration = PostgresEnumMigration("my_enum", ("val_a", "val_b")) + schema_editor, mock_cursor = self._make_mock_schema_editor() + + migration.create_enum_type(apps=None, schema_editor=schema_editor) + + mock_cursor.execute.assert_called_once() + query_arg = mock_cursor.execute.call_args[0][0] + assert isinstance( + query_arg, psycopg2_sql.Composable + ), "create_enum_type must pass a psycopg2.sql.Composable, not a raw string." + # Verify the composed SQL structure: CREATE TYPE AS ENUM () + parts = query_arg.seq + assert parts[0] == psycopg2_sql.SQL("CREATE TYPE ") + assert isinstance(parts[1], psycopg2_sql.Identifier) + assert parts[1].strings == ("my_enum",) + assert parts[2] == psycopg2_sql.SQL(" AS ENUM (") + # The enum values are a Composed of Literal items joined by ", " + enum_literals = [p for p in parts[3].seq if isinstance(p, psycopg2_sql.Literal)] + assert [lit._wrapped for lit in enum_literals] == ["val_a", "val_b"] + assert parts[4] == psycopg2_sql.SQL(")") + + def test_drop_enum_type_generates_correct_sql(self): + """drop_enum_type builds a proper DROP TYPE via psycopg2.sql.""" + migration = PostgresEnumMigration("my_enum", ("val_a",)) + schema_editor, mock_cursor = self._make_mock_schema_editor() + + migration.drop_enum_type(apps=None, schema_editor=schema_editor) + + mock_cursor.execute.assert_called_once() + query_arg = mock_cursor.execute.call_args[0][0] + assert isinstance( + query_arg, psycopg2_sql.Composable + ), "drop_enum_type must pass a psycopg2.sql.Composable, not a raw string." + # Verify the composed SQL structure: DROP TYPE + parts = query_arg.seq + assert parts[0] == psycopg2_sql.SQL("DROP TYPE ") + assert isinstance(parts[1], psycopg2_sql.Identifier) + assert parts[1].strings == ("my_enum",) diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index 13878794b1..b8b7f61dd1 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -243,6 +243,39 @@ class TestSAMLConfigurationModel: assert "Invalid XML" in errors["metadata_xml"][0] assert "not well-formed" in errors["metadata_xml"][0] + def test_xml_bomb_rejected(self, tenants_fixture): + """ + Regression test: a 'billion laughs' XML bomb in the SAML metadata field + must be rejected and not allowed to exhaust server memory / CPU. + + Before the fix, xml.etree.ElementTree was used directly, which does not + protect against entity-expansion attacks. The fix switches to defusedxml + which raises an exception for any XML containing entity definitions. + """ + tenant = tenants_fixture[0] + xml_bomb = ( + "" + "" + " " + " " + " " + "]>" + "" + ) + config = SAMLConfiguration( + email_domain="xmlbomb.com", + metadata_xml=xml_bomb, + tenant=tenant, + ) + + with pytest.raises(ValidationError) as exc_info: + config._parse_metadata() + + errors = exc_info.value.message_dict + assert "metadata_xml" in errors + def test_metadata_missing_sso_fails(self, tenants_fixture): tenant = tenants_fixture[0] xml = """ diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index addd8b4ec6..684a9e44b9 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -2,7 +2,7 @@ import json from unittest.mock import ANY, Mock, patch import pytest -from conftest import TODAY +from conftest import TEST_PASSWORD, TODAY from django.urls import reverse from rest_framework import status @@ -830,3 +830,66 @@ class TestUserRoleLinkPermissions: ) assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestCrossTenantRoleLeak: + """Regression tests for get_role() cross-tenant privilege leak. + + get_role() must query admin_db (bypassing RLS) so that a user with a role + in tenant A cannot accidentally pass role checks when authenticated against + tenant B where they have no role. + """ + + def test_user_with_role_in_tenant_a_denied_in_tenant_b(self, tenants_fixture): + """User has admin role in tenant A, membership in tenant B but no role. + Hitting an RBAC-protected endpoint with a tenant-B token must return 403.""" + from rest_framework.test import APIClient + + tenant_a = tenants_fixture[0] + tenant_b = tenants_fixture[1] + + user = User.objects.create_user( + name="cross_tenant_user", + email="cross_tenant@test.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=user, tenant=tenant_a, role=Membership.RoleChoices.OWNER + ) + Membership.objects.create( + user=user, tenant=tenant_b, role=Membership.RoleChoices.OWNER + ) + + # Role only in tenant A + role = Role.objects.create( + name="admin", + tenant_id=tenant_a.id, + manage_users=True, + manage_account=True, + manage_billing=True, + manage_providers=True, + manage_integrations=True, + manage_scans=True, + unlimited_visibility=True, + ) + UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant_a.id) + + # Mint token scoped to tenant B (where user has NO role) + serializer = TokenSerializer( + data={ + "type": "tokens", + "email": "cross_tenant@test.com", + "password": TEST_PASSWORD, + "tenant_id": tenant_b.id, + } + ) + serializer.is_valid(raise_exception=True) + access_token = serializer.validated_data["access"] + + client = APIClient() + client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}" + + # user-list requires manage_users permission via HasPermissions + response = client.get(reverse("user-list")) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/api/src/backend/api/tests/test_sentry.py b/api/src/backend/api/tests/test_sentry.py index cf71593469..fb7abaffb4 100644 --- a/api/src/backend/api/tests/test_sentry.py +++ b/api/src/backend/api/tests/test_sentry.py @@ -4,14 +4,25 @@ from unittest.mock import MagicMock from config.settings.sentry import before_send +def _make_log_record(msg, level=logging.ERROR, name="test", args=None): + """Build a real LogRecord so getMessage() works like in production.""" + record = logging.LogRecord( + name=name, + level=level, + pathname="", + lineno=0, + msg=msg, + args=args, + exc_info=None, + ) + return record + + def test_before_send_ignores_log_with_ignored_exception(): """Test that before_send ignores logs containing ignored exceptions.""" - log_record = MagicMock() - log_record.msg = "Provider kubernetes is not connected" - log_record.levelno = logging.ERROR # 40 + log_record = _make_log_record("Provider kubernetes is not connected") hint = {"log_record": log_record} - event = MagicMock() result = before_send(event, hint) @@ -36,12 +47,9 @@ def test_before_send_ignores_exception_with_ignored_exception(): def test_before_send_passes_through_non_ignored_log(): """Test that before_send passes through logs that don't contain ignored exceptions.""" - log_record = MagicMock() - log_record.msg = "Some other error message" - log_record.levelno = logging.ERROR # 40 + log_record = _make_log_record("Some other error message") hint = {"log_record": log_record} - event = MagicMock() result = before_send(event, hint) @@ -66,15 +74,53 @@ def test_before_send_passes_through_non_ignored_exception(): def test_before_send_handles_warning_level(): """Test that before_send handles warning level logs.""" - log_record = MagicMock() - log_record.msg = "Provider kubernetes is not connected" - log_record.levelno = logging.WARNING # 30 + log_record = _make_log_record( + "Provider kubernetes is not connected", level=logging.WARNING + ) hint = {"log_record": log_record} - event = MagicMock() result = before_send(event, hint) # Assert that the event was dropped (None returned) assert result is None + + +def test_before_send_ignores_neo4j_defunct_connection(): + """Test that before_send drops neo4j.io defunct connection logs. + + The Neo4j driver logs transient connection errors at ERROR level + before RetryableSession retries them. These are noise. + + The driver uses %s formatting, so "defunct" is in the args, not + in the template. This test mirrors the real LogRecord structure. + """ + log_record = _make_log_record( + msg="[#%04X] _: error: %s: %r", + name="neo4j.io", + args=( + 0xE5CC, + "Failed to read from defunct connection " + "IPv4Address(('cloud-neo4j.prowler.com', 7687))", + ConnectionResetError(104, "Connection reset by peer"), + ), + ) + + hint = {"log_record": log_record} + event = MagicMock() + + assert before_send(event, hint) is None + + +def test_before_send_passes_non_defunct_neo4j_log(): + """Test that before_send passes through neo4j.io logs that are not about defunct connections.""" + log_record = _make_log_record( + msg="Some other neo4j transport error", + name="neo4j.io", + ) + + hint = {"log_record": log_record} + event = MagicMock() + + assert before_send(event, hint) == event diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 24c051717d..86da5567da 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -23,6 +23,9 @@ from prowler.providers.azure.azure_provider import AzureProvider from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider from prowler.providers.gcp.gcp_provider import GcpProvider from prowler.providers.github.github_provider import GithubProvider +from prowler.providers.googleworkspace.googleworkspace_provider import ( + GoogleworkspaceProvider, +) from prowler.providers.iac.iac_provider import IacProvider from prowler.providers.image.image_provider import ImageProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider @@ -30,6 +33,7 @@ from prowler.providers.m365.m365_provider import M365Provider from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider +from prowler.providers.vercel.vercel_provider import VercelProvider class TestMergeDicts: @@ -113,6 +117,7 @@ class TestReturnProwlerProvider: [ (Provider.ProviderChoices.AWS.value, AwsProvider), (Provider.ProviderChoices.GCP.value, GcpProvider), + (Provider.ProviderChoices.GOOGLEWORKSPACE.value, GoogleworkspaceProvider), (Provider.ProviderChoices.AZURE.value, AzureProvider), (Provider.ProviderChoices.KUBERNETES.value, KubernetesProvider), (Provider.ProviderChoices.M365.value, M365Provider), @@ -124,6 +129,7 @@ class TestReturnProwlerProvider: (Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider), (Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider), (Provider.ProviderChoices.IMAGE.value, ImageProvider), + (Provider.ProviderChoices.VERCEL.value, VercelProvider), ], ) def test_return_prowler_provider(self, provider_type, expected_provider): @@ -214,6 +220,24 @@ class TestProwlerProviderConnectionTest: registry_token="tok123", ) + @patch("api.utils.return_prowler_provider") + def test_prowler_provider_connection_test_vercel_provider( + self, mock_return_prowler_provider + ): + """Test connection test for Vercel provider passes team_id.""" + provider = MagicMock() + provider.uid = "team_abcdef1234567890" + provider.provider = Provider.ProviderChoices.VERCEL.value + provider.secret.secret = {"api_token": "vercel_token_123"} + mock_return_prowler_provider.return_value = MagicMock() + + prowler_provider_connection_test(provider) + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + api_token="vercel_token_123", + team_id="team_abcdef1234567890", + raise_on_exception=False, + ) + @patch("api.utils.return_prowler_provider") def test_prowler_provider_connection_test_image_provider_no_creds( self, mock_return_prowler_provider @@ -248,6 +272,10 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.GCP.value, {"project_ids": ["provider_uid"]}, ), + ( + Provider.ProviderChoices.GOOGLEWORKSPACE.value, + {}, + ), ( Provider.ProviderChoices.KUBERNETES.value, {"context": "provider_uid"}, @@ -276,6 +304,10 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.OPENSTACK.value, {}, ), + ( + Provider.ProviderChoices.VERCEL.value, + {"team_id": "provider_uid"}, + ), ], ) def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs): @@ -774,11 +806,15 @@ class TestProwlerIntegrationConnectionTest: } integration.configuration = {} - # Mock successful JIRA connection with projects + # Mock successful JIRA connection with projects and issue types mock_connection = MagicMock() mock_connection.is_connected = True mock_connection.error = None mock_connection.projects = {"PROJ1": "Project 1", "PROJ2": "Project 2"} + mock_connection.issue_types = { + "PROJ1": ["Task", "Bug"], + "PROJ2": ["Task", "Story"], + } mock_jira_class.test_connection.return_value = mock_connection # Mock rls_transaction context manager @@ -807,6 +843,12 @@ class TestProwlerIntegrationConnectionTest: "PROJ2": "Project 2", } + # Verify issue types were saved to integration configuration + assert integration.configuration["issue_types"] == { + "PROJ1": ["Task", "Bug"], + "PROJ2": ["Task", "Story"], + } + # Verify integration.save() was called integration.save.assert_called_once() @@ -830,6 +872,7 @@ class TestProwlerIntegrationConnectionTest: mock_connection.is_connected = False mock_connection.error = Exception("Authentication failed: Invalid credentials") mock_connection.projects = {} # Empty projects when connection fails + mock_connection.issue_types = {} # Empty issue types when connection fails mock_jira_class.test_connection.return_value = mock_connection # Mock rls_transaction context manager @@ -855,6 +898,9 @@ class TestProwlerIntegrationConnectionTest: # Verify empty projects dict was saved to integration configuration assert integration.configuration["projects"] == {} + # Verify empty issue types dict was saved to integration configuration + assert integration.configuration["issue_types"] == {} + # Verify integration.save() was called even on connection failure integration.save.assert_called_once() @@ -873,11 +919,11 @@ class TestProwlerIntegrationConnectionTest: "domain": "example.atlassian.net", } integration.configuration = { - "issue_types": ["Task"], # Existing configuration + "issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration "projects": {"OLD_PROJ": "Old Project"}, # Will be overwritten } - # Mock successful JIRA connection with new projects + # Mock successful JIRA connection with new projects and issue types mock_connection = MagicMock() mock_connection.is_connected = True mock_connection.error = None @@ -885,6 +931,10 @@ class TestProwlerIntegrationConnectionTest: "NEW_PROJ1": "New Project 1", "NEW_PROJ2": "New Project 2", } + mock_connection.issue_types = { + "NEW_PROJ1": ["Task", "Bug"], + "NEW_PROJ2": ["Story"], + } mock_jira_class.test_connection.return_value = mock_connection # Mock rls_transaction context manager @@ -902,8 +952,11 @@ class TestProwlerIntegrationConnectionTest: "NEW_PROJ2": "New Project 2", } - # Verify other configuration fields were preserved - assert integration.configuration["issue_types"] == ["Task"] + # Verify issue types were also updated + assert integration.configuration["issue_types"] == { + "NEW_PROJ1": ["Task", "Bug"], + "NEW_PROJ2": ["Story"], + } # Verify integration.save() was called integration.save.assert_called_once() diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 22e7ab091e..d30e786e5f 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -516,6 +516,13 @@ class TestTenantViewSet: response.json()["data"]["attributes"]["name"] == valid_tenant_payload["name"] ) + new_tenant_id = response.json()["data"]["id"] + user = authenticated_client.user + assert UserRoleRelationship.objects.filter( + user=user, + tenant_id=new_tenant_id, + role__name="admin", + ).exists() def test_tenants_invalid_create(self, authenticated_client, invalid_tenant_payload): response = authenticated_client.post( @@ -575,22 +582,66 @@ class TestTenantViewSet: Tenant.objects.filter(pk=kwargs.get("tenant_id")).delete() delete_tenant_mock.side_effect = _delete_tenant + # Use tenant2 where the user is OWNER + _, tenant2, _ = tenants_fixture + response = authenticated_client.delete( + reverse("tenant-detail", kwargs={"pk": tenant2.id}) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert Membership.objects.filter(tenant_id=tenant2.id).count() == 0 + # User is not deleted because it has another membership (tenant1) + assert User.objects.count() == 1 + + @patch("api.v1.views.delete_tenant_task.apply_async") + def test_tenants_delete_as_member_forbidden( + self, delete_tenant_mock, authenticated_client, tenants_fixture + ): + # tenant1: user is MEMBER, not OWNER -> should be forbidden tenant1, *_ = tenants_fixture response = authenticated_client.delete( reverse("tenant-detail", kwargs={"pk": tenant1.id}) ) + assert response.status_code == status.HTTP_403_FORBIDDEN + delete_tenant_mock.assert_not_called() + + @patch("api.v1.views.delete_tenant_task.apply_async") + def test_tenants_delete_cross_tenant( + self, delete_tenant_mock, authenticated_client, tenants_fixture + ): + # tenant3: user has no membership -> should be 404 + _, _, tenant3 = tenants_fixture + response = authenticated_client.delete( + reverse("tenant-detail", kwargs={"pk": tenant3.id}) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + delete_tenant_mock.assert_not_called() + + @patch("api.v1.views.delete_tenant_task.apply_async") + def test_tenants_delete_only_removes_exclusive_users( + self, delete_tenant_mock, authenticated_client, tenants_fixture, extra_users + ): + def _delete_tenant(kwargs): + Tenant.objects.filter(pk=kwargs.get("tenant_id")).delete() + + delete_tenant_mock.side_effect = _delete_tenant + _, tenant2, _ = tenants_fixture + # extra_users adds user2 (OWNER in tenant2) and user3 (MEMBER in tenant2) + # user2 and user3 are ONLY in tenant2, so they should be deleted + # The test user is in tenant1 + tenant2, so should NOT be deleted + initial_user_count = User.objects.count() # test_user + user2 + user3 = 3 + assert initial_user_count == 3 + + response = authenticated_client.delete( + reverse("tenant-detail", kwargs={"pk": tenant2.id}) + ) assert response.status_code == status.HTTP_204_NO_CONTENT - assert Tenant.objects.count() == len(tenants_fixture) - 1 - assert Membership.objects.filter(tenant_id=tenant1.id).count() == 0 - # User is not deleted because it has another membership + # user2 and user3 are deleted (no other memberships), test_user remains assert User.objects.count() == 1 def test_tenants_delete_invalid(self, authenticated_client): response = authenticated_client.delete( reverse("tenant-detail", kwargs={"pk": "random_id"}) ) - # To change if we implement RBAC - # (user might not have permissions to see if the tenant exists or not -> 200 empty) assert response.status_code == status.HTTP_404_NOT_FOUND def test_tenants_list_filter_search(self, authenticated_client, tenants_fixture): @@ -694,7 +745,6 @@ class TestTenantViewSet: # Test user + 2 extra users for tenant 2 assert len(response.json()["data"]) == 3 - @patch("api.v1.views.TenantMembersViewSet.required_permissions", []) def test_tenants_list_memberships_as_member( self, authenticated_client, tenants_fixture, extra_users ): @@ -807,6 +857,87 @@ class TestTenantViewSet: ) assert response.status_code == status.HTTP_404_NOT_FOUND + def test_tenants_delete_membership_cross_tenant( + self, authenticated_client, tenants_fixture + ): + # Create a tenant with a different user's membership + other_tenant = Tenant.objects.create(name="Other Tenant") + other_user = User.objects.create_user( + name="other", password=TEST_PASSWORD, email="other@test.com" + ) + other_membership = Membership.objects.create( + user=other_user, + tenant=other_tenant, + role=Membership.RoleChoices.OWNER, + ) + + # Authenticated user is NOT a member of other_tenant -> 404 + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": other_tenant.id, "pk": other_membership.id}, + ) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert Membership.objects.filter(id=other_membership.id).exists() + + def test_tenants_list_no_permissions( + self, authenticated_client_no_permissions_rbac, tenants_fixture + ): + response = authenticated_client_no_permissions_rbac.get(reverse("tenant-list")) + assert response.status_code == status.HTTP_200_OK + + def test_tenants_retrieve_no_permissions( + self, authenticated_client_no_permissions_rbac, tenants_fixture + ): + tenant1, *_ = tenants_fixture + response = authenticated_client_no_permissions_rbac.get( + reverse("tenant-detail", kwargs={"pk": tenant1.id}) + ) + assert response.status_code == status.HTTP_200_OK + + def test_tenants_create_no_permissions( + self, authenticated_client_no_permissions_rbac, valid_tenant_payload + ): + response = authenticated_client_no_permissions_rbac.post( + reverse("tenant-list"), + data=valid_tenant_payload, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + + def test_tenants_partial_update_no_permissions( + self, authenticated_client_no_permissions_rbac, tenants_fixture + ): + tenant1, *_ = tenants_fixture + payload = { + "data": { + "type": "tenants", + "id": str(tenant1.id), + "attributes": {"name": "Unauthorized update"}, + }, + } + response = authenticated_client_no_permissions_rbac.patch( + reverse("tenant-detail", kwargs={"pk": tenant1.id}), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + @patch("api.v1.views.delete_tenant_task.apply_async") + def test_tenants_delete_no_permissions( + self, + delete_tenant_mock, + authenticated_client_no_permissions_rbac, + tenants_fixture, + ): + tenant1, *_ = tenants_fixture + response = authenticated_client_no_permissions_rbac.delete( + reverse("tenant-detail", kwargs={"pk": tenant1.id}) + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + delete_tenant_mock.assert_not_called() + @pytest.mark.django_db class TestMembershipViewSet: @@ -1190,6 +1321,26 @@ class TestProviderViewSet: "uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "alias": "OpenStack Project", }, + { + "provider": "googleworkspace", + "uid": "C01234abc", + "alias": "Google Workspace Customer", + }, + { + "provider": "googleworkspace", + "uid": "C12345678", + "alias": "Google Workspace All Digits", + }, + { + "provider": "googleworkspace", + "uid": "CABCDEF123", + "alias": "Google Workspace Uppercase", + }, + { + "provider": "googleworkspace", + "uid": "C12", + "alias": "Google Workspace Minimum Length", + }, ] ), ) @@ -1342,7 +1493,11 @@ class TestProviderViewSet: response = authenticated_client.post( reverse("provider-list"), data=provider_json_payload, format="json" ) - assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.status_code == status.HTTP_409_CONFLICT + error = response.json()["errors"][0] + assert error["detail"] == "Provider already exists." + assert error["code"] == "conflict" + assert error["source"]["pointer"] == "/data/attributes/uid" mock_delete_task.reset_mock() mock_delete_task.return_value = task_mock @@ -1634,6 +1789,76 @@ class TestProviderViewSet: "min_length", "uid", ), + # Vercel UID validation - missing team_ prefix + ( + { + "provider": "vercel", + "uid": "abcdef1234567890abcdef12", + "alias": "test", + }, + "vercel-uid", + "uid", + ), + # Vercel UID validation - too short after prefix + ( + { + "provider": "vercel", + "uid": "team_abc123", + "alias": "test", + }, + "vercel-uid", + "uid", + ), + # Vercel UID validation - contains special characters + ( + { + "provider": "vercel", + "uid": "team_abcdef-1234567890ab", + "alias": "test", + }, + "vercel-uid", + "uid", + ), + # Vercel UID validation - too long (33 chars after prefix) + ( + { + "provider": "vercel", + "uid": "team_abcdefghijklmnopqrstuvwxyz1234567", + "alias": "test", + }, + "vercel-uid", + "uid", + ), + # Google Workspace UID validation - missing 'C' prefix + ( + { + "provider": "googleworkspace", + "uid": "01234abc", + "alias": "test", + }, + "googleworkspace-uid", + "uid", + ), + # Google Workspace UID validation - contains special characters + ( + { + "provider": "googleworkspace", + "uid": "C0123-abc", + "alias": "test", + }, + "googleworkspace-uid", + "uid", + ), + # Google Workspace UID validation - lowercase 'c' prefix + ( + { + "provider": "googleworkspace", + "uid": "c12345678", + "alias": "test", + }, + "googleworkspace-uid", + "uid", + ), ] ), ) @@ -1807,21 +2032,21 @@ class TestProviderViewSet: ( "uid.icontains", "1", - 10, + 12, ), ("alias", "aws_testing_1", 1), ("alias.icontains", "aws", 2), - ("inserted_at", TODAY, 11), + ("inserted_at", TODAY, 13), ( "inserted_at.gte", "2024-01-01", - 11, + 13, ), ("inserted_at.lte", "2024-01-01", 0), ( "updated_at.gte", "2024-01-01", - 11, + 13, ), ("updated_at.lte", "2024-01-01", 0), ] @@ -2437,6 +2662,23 @@ class TestProviderSecretViewSet: "clouds_yaml_cloud": "mycloud", }, ), + # Google Workspace with service account credentials + ( + Provider.ProviderChoices.GOOGLEWORKSPACE.value, + ProviderSecret.TypeChoices.STATIC, + { + "credentials_content": '{"type": "service_account", "project_id": "test-project", "private_key_id": "key123", "private_key": "-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----\\n", "client_email": "test@test-project.iam.gserviceaccount.com", "client_id": "123456789"}', + "delegated_user": "admin@example.com", + }, + ), + # Vercel with API Token + ( + Provider.ProviderChoices.VERCEL.value, + ProviderSecret.TypeChoices.STATIC, + { + "api_token": "fake-vercel-api-token-for-testing", + }, + ), ], ) def test_provider_secrets_create_valid( @@ -3115,6 +3357,29 @@ class TestScanViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 3 + def test_scan_filter_by_id_exact(self, authenticated_client, scans_fixture): + scan1, *_ = scans_fixture + response = authenticated_client.get( + reverse("scan-list"), + {"filter[id]": str(scan1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == str(scan1.id) + + def test_scan_filter_by_id_in(self, authenticated_client, scans_fixture): + scan1, scan2, *_ = scans_fixture + response = authenticated_client.get( + reverse("scan-list"), + {"filter[id.in]": f"{scan1.id},{scan2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + returned_ids = {item["id"] for item in data} + assert returned_ids == {str(scan1.id), str(scan2.id)} + def test_scans_filter_state_failed(self, authenticated_client, scans_fixture): """Ensure state filter matches only FAILED scans.""" scan1, *_ = scans_fixture @@ -3747,6 +4012,12 @@ class TestTaskViewSet: @pytest.mark.django_db class TestAttackPathsScanViewSet: + @pytest.fixture(autouse=True) + def _clear_throttle_cache(self): + from django.core.cache import cache + + cache.clear() + @staticmethod def _run_payload(query_id="aws-rds", parameters=None): return { @@ -4016,7 +4287,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.attack_paths_views_helpers.execute_query", return_value=graph_payload, ) as mock_execute, - patch("api.v1.views.graph_database.clear_cache") as mock_clear_cache, ): response = authenticated_client.post( reverse( @@ -4043,7 +4313,6 @@ class TestAttackPathsScanViewSet: prepared_parameters, provider_id, ) - mock_clear_cache.assert_called_once_with(expected_db_name) result = response.json()["data"] attributes = result["attributes"] assert attributes["nodes"] == graph_payload["nodes"] @@ -4098,7 +4367,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.attack_paths_views_helpers.execute_query", return_value=graph_payload, ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4182,7 +4450,6 @@ class TestAttackPathsScanViewSet: "truncated": False, }, ), - patch("api.v1.views.graph_database.clear_cache"), patch( "api.v1.views.graph_database.get_database_name", return_value="db-test" ), @@ -4237,7 +4504,6 @@ class TestAttackPathsScanViewSet: "truncated": False, }, ), - patch("api.v1.views.graph_database.clear_cache"), patch( "api.v1.views.graph_database.get_database_name", return_value="db-test" ), @@ -4317,7 +4583,6 @@ class TestAttackPathsScanViewSet: "truncated": False, }, ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4348,8 +4613,6 @@ class TestAttackPathsScanViewSet: } } - # TODO: Remove skip once queries/custom and schema endpoints are unblocked - @pytest.mark.skip(reason="Endpoint temporarily blocked") def test_run_custom_query_returns_graph( self, authenticated_client, @@ -4385,7 +4648,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.graph_database.get_database_name", return_value="db-test", ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4407,7 +4669,6 @@ class TestAttackPathsScanViewSet: assert attributes["total_nodes"] == 1 assert attributes["truncated"] is False - @pytest.mark.skip(reason="Endpoint temporarily blocked") def test_run_custom_query_returns_text_when_accept_text_plain( self, authenticated_client, @@ -4443,7 +4704,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.graph_database.get_database_name", return_value="db-test", ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4462,7 +4722,6 @@ class TestAttackPathsScanViewSet: assert "## Relationships (0)" in body assert "## Summary" in body - @pytest.mark.skip(reason="Endpoint temporarily blocked") def test_run_custom_query_returns_404_when_no_nodes( self, authenticated_client, @@ -4491,7 +4750,6 @@ class TestAttackPathsScanViewSet: "api.v1.views.graph_database.get_database_name", return_value="db-test", ), - patch("api.v1.views.graph_database.clear_cache"), ): response = authenticated_client.post( reverse( @@ -4504,7 +4762,6 @@ class TestAttackPathsScanViewSet: assert response.status_code == status.HTTP_404_NOT_FOUND - @pytest.mark.skip(reason="Endpoint temporarily blocked") def test_run_custom_query_returns_400_when_graph_not_ready( self, authenticated_client, @@ -4531,7 +4788,6 @@ class TestAttackPathsScanViewSet: assert response.status_code == status.HTTP_400_BAD_REQUEST assert "not available" in response.json()["errors"][0]["detail"] - @pytest.mark.skip(reason="Endpoint temporarily blocked") def test_run_custom_query_returns_403_for_write_query( self, authenticated_client, @@ -4569,9 +4825,340 @@ class TestAttackPathsScanViewSet: assert response.status_code == status.HTTP_403_FORBIDDEN + # -- SSRF blocklist (HTTP level) ---------------------------------------------- + + @pytest.mark.parametrize( + "cypher", + [ + "LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x", + "CALL apoc.load.json('http://evil.com/') YIELD value RETURN value", + "CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node", + "CALL apoc.export.csv.all('file.csv', {})", + "CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value", + "CALL apoc.systemdb.graph() YIELD nodes RETURN nodes", + ], + ids=[ + "LOAD_CSV", + "apoc.load", + "apoc.import", + "apoc.export", + "apoc.cypher.run", + "apoc.systemdb", + ], + ) + def test_run_custom_query_rejects_ssrf_patterns( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + cypher, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_data_ready=True, + ) + + with patch( + "api.v1.views.graph_database.get_database_name", + return_value="db-test", + ): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-custom", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._custom_query_payload(cypher), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "blocked" in response.json()["errors"][0]["detail"].lower() + + # -- Cross-tenant isolation --------------------------------------------------- + + def test_run_custom_query_returns_404_for_foreign_tenant( + self, + authenticated_client, + create_attack_paths_scan, + ): + from api.models import Provider, Tenant + + foreign_tenant = Tenant.objects.create(name="foreign-tenant") + foreign_provider = Provider.objects.create( + tenant=foreign_tenant, + provider="aws", + uid="123456789999", + ) + attack_paths_scan = create_attack_paths_scan( + foreign_provider, + graph_data_ready=True, + ) + + with patch( + "api.v1.views.graph_database.get_database_name", + return_value="db-test", + ): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-custom", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._custom_query_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_cartography_schema_returns_404_for_foreign_tenant( + self, + authenticated_client, + create_attack_paths_scan, + ): + from api.models import Provider, Tenant + + foreign_tenant = Tenant.objects.create(name="foreign-tenant-schema") + foreign_provider = Provider.objects.create( + tenant=foreign_tenant, + provider="aws", + uid="123456789998", + ) + attack_paths_scan = create_attack_paths_scan( + foreign_provider, + graph_data_ready=True, + ) + + response = authenticated_client.get( + reverse( + "attack-paths-scans-schema", + kwargs={"pk": attack_paths_scan.id}, + ) + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + # -- Authentication / authorization ------------------------------------------- + + def test_run_custom_query_returns_401_unauthenticated( + self, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + from rest_framework.test import APIClient + + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_data_ready=True, + ) + + unauthenticated = APIClient() + response = unauthenticated.post( + reverse( + "attack-paths-scans-queries-custom", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._custom_query_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_cartography_schema_returns_401_unauthenticated( + self, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + from rest_framework.test import APIClient + + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_data_ready=True, + ) + + unauthenticated = APIClient() + response = unauthenticated.get( + reverse( + "attack-paths-scans-schema", + kwargs={"pk": attack_paths_scan.id}, + ) + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_run_custom_query_returns_403_no_manage_scans( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_data_ready=True, + ) + + response = authenticated_client_no_permissions_rbac.post( + reverse( + "attack-paths-scans-queries-custom", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._custom_query_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + # -- Error leakage ------------------------------------------------------------ + + def test_run_custom_query_does_not_leak_internals_on_error( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + from rest_framework.exceptions import APIException + + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_data_ready=True, + ) + + with ( + patch( + "api.v1.views.attack_paths_views_helpers.execute_custom_query", + side_effect=APIException( + "Attack Paths query execution failed due to a database error" + ), + ), + patch( + "api.v1.views.graph_database.get_database_name", + return_value="db-test", + ), + ): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-custom", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._custom_query_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + body = json.dumps(response.json()).lower() + for forbidden_term in ["neo4j", "bolt://", "syntaxerror", "db-tenant-"]: + assert forbidden_term not in body + + # -- Rate limiting (throttle) ------------------------------------------------- + + def test_run_custom_query_throttled_after_limit( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_data_ready=True, + ) + + mock_graph = { + "nodes": [{"id": "n1", "labels": ["Test"], "properties": {}}], + "relationships": [], + "total_nodes": 1, + "truncated": False, + } + + url = reverse( + "attack-paths-scans-queries-custom", + kwargs={"pk": attack_paths_scan.id}, + ) + payload = self._custom_query_payload() + + with ( + patch( + "api.v1.views.attack_paths_views_helpers.execute_custom_query", + return_value=mock_graph, + ), + patch( + "api.v1.views.graph_database.get_database_name", + return_value="db-test", + ), + ): + for i in range(11): + response = authenticated_client.post( + url, + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + if i < 10: + assert ( + response.status_code == status.HTTP_200_OK + ), f"Request {i + 1} should succeed with 200 OK, got {response.status_code}" + else: + assert ( + response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + ), f"Request {i + 1} should be throttled" + + # -- Timeout simulation ------------------------------------------------------- + + def test_run_custom_query_returns_500_on_database_timeout( + self, + authenticated_client, + providers_fixture, + scans_fixture, + create_attack_paths_scan, + ): + from rest_framework.exceptions import APIException + + provider = providers_fixture[0] + attack_paths_scan = create_attack_paths_scan( + provider, + scan=scans_fixture[0], + graph_data_ready=True, + ) + + with ( + patch( + "api.v1.views.attack_paths_views_helpers.execute_custom_query", + side_effect=APIException( + "Attack Paths query execution failed due to a database error" + ), + ), + patch( + "api.v1.views.graph_database.get_database_name", + return_value="db-test", + ), + ): + response = authenticated_client.post( + reverse( + "attack-paths-scans-queries-custom", + kwargs={"pk": attack_paths_scan.id}, + ), + data=self._custom_query_payload(), + content_type=API_JSON_CONTENT_TYPE, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + # -- cartography_schema action ------------------------------------------------ - @pytest.mark.skip(reason="Endpoint temporarily blocked") def test_cartography_schema_returns_urls( self, authenticated_client, @@ -4621,7 +5208,6 @@ class TestAttackPathsScanViewSet: assert "schema.md" in attributes["schema_url"] assert "raw.githubusercontent.com" in attributes["raw_schema_url"] - @pytest.mark.skip(reason="Endpoint temporarily blocked") def test_cartography_schema_returns_404_when_no_metadata( self, authenticated_client, @@ -4656,7 +5242,6 @@ class TestAttackPathsScanViewSet: assert response.status_code == status.HTTP_404_NOT_FOUND assert "No cartography schema metadata" in str(response.json()) - @pytest.mark.skip(reason="Endpoint temporarily blocked") def test_cartography_schema_returns_400_when_graph_not_ready( self, authenticated_client, @@ -7463,8 +8048,12 @@ class TestUserRoleRelationshipViewSet: assert response.status_code == status.HTTP_204_NO_CONTENT relationships = UserRoleRelationship.objects.filter(user=create_test_user.id) assert relationships.count() == 4 - for relationship in relationships[2:]: # Skip admin role - assert relationship.role.id in [r.id for r in roles_fixture[:2]] + # Use set membership instead of positional slicing — QuerySet ordering is + # non-deterministic without an explicit order_by, which makes slice-based + # checks intermittently fail. + added_role_ids = {r.id for r in roles_fixture[:2]} + relationship_role_ids = {rel.role.id for rel in relationships} + assert added_role_ids.issubset(relationship_role_ids) def test_create_relationship_already_exists( self, authenticated_client, roles_fixture, create_test_user @@ -7641,6 +8230,8 @@ class TestUserRoleRelationshipViewSet: manage_scans=False, unlimited_visibility=False, ) + # Assign the role to the user + UserRoleRelationship.objects.create(user=user, role=only_role, tenant=tenant) # Switch token to this tenant serializer = TokenSerializer( @@ -14290,10 +14881,16 @@ class TestMuteRuleViewSet: assert len(data) == 2 assert data[0]["id"] == str(mute_rules_fixture[first_index].id) - @patch("tasks.tasks.mute_historical_findings_task.apply_async") + @patch("api.v1.views.chain") + @patch("api.v1.views.reaggregate_all_finding_group_summaries_task.si") + @patch("api.v1.views.mute_historical_findings_task.si") + @patch("api.v1.views.transaction.on_commit", side_effect=lambda fn: fn()) def test_mute_rules_create_valid( self, - mock_task, + _mock_on_commit, + mock_mute_signature, + mock_reaggregate_signature, + mock_chain, authenticated_client, findings_fixture, create_test_user, @@ -14331,8 +14928,14 @@ class TestMuteRuleViewSet: assert finding.muted_at is not None assert finding.muted_reason == "Security exception approved" - # Verify background task was called - mock_task.assert_called_once() + # Verify background task chain was called: mute → reaggregate all + mock_mute_signature.assert_called_once() + mock_reaggregate_signature.assert_called_once() + mock_chain.assert_called_once_with( + mock_mute_signature.return_value, + mock_reaggregate_signature.return_value, + ) + mock_chain.return_value.apply_async.assert_called_once() @patch("tasks.tasks.mute_historical_findings_task.apply_async") def test_mute_rules_create_converts_finding_ids_to_uids( @@ -14805,6 +15408,29 @@ class TestFindingGroupViewSet: # ec2_instance_public_ip has 1 PASS and 1 FAIL, should aggregate to FAIL assert data[0]["attributes"]["status"] == "FAIL" + def test_finding_groups_region_filter_reaggregates_metrics( + self, authenticated_client, finding_groups_fixture + ): + """Test finding-level filters recompute group metrics from matching findings.""" + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[check_id]": "ec2_instance_public_ip", + "filter[region]": "us-east-1", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + + attrs = data[0]["attributes"] + assert attrs["status"] == "PASS" + assert attrs["pass_count"] == 1 + assert attrs["fail_count"] == 0 + assert attrs["resources_total"] == 1 + assert attrs["resources_fail"] == 0 + def test_finding_groups_status_pass_when_no_fail( self, authenticated_client, finding_groups_fixture ): @@ -14833,6 +15459,182 @@ class TestFindingGroupViewSet: # rds_encryption has all muted findings assert data[0]["attributes"]["status"] == "MUTED" + def test_finding_groups_status_filter( + self, authenticated_client, finding_groups_fixture + ): + """Test finding groups can be filtered by aggregated status.""" + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[status]": "FAIL"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + assert all(item["attributes"]["status"] == "FAIL" for item in data) + + def test_finding_groups_status_in_filter( + self, authenticated_client, finding_groups_fixture + ): + """Test finding groups support status__in filter on aggregated status.""" + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[status__in]": "FAIL,PASS"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + assert all(item["attributes"]["status"] in {"FAIL", "PASS"} for item in data) + + def test_finding_groups_severity_filter( + self, authenticated_client, finding_groups_fixture + ): + """Test finding groups can be filtered by aggregated severity.""" + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[severity]": "critical"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + assert all(item["attributes"]["severity"] == "critical" for item in data) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_combined_region_and_status_filters( + self, authenticated_client, finding_groups_fixture, endpoint_name + ): + """Test combined region + aggregated status filters.""" + params = {"filter[region]": "us-east-1", "filter[status]": "FAIL"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + check_ids = {item["id"] for item in data} + assert check_ids == {"s3_bucket_public_access", "cloudtrail_enabled"} + assert all(item["attributes"]["status"] == "FAIL" for item in data) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + def test_finding_groups_combined_delta_and_severity_filters( + self, authenticated_client, finding_groups_fixture, endpoint_name + ): + """Test combined delta + aggregated severity filters.""" + params = {"filter[delta]": "new", "filter[severity]": "critical"} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + check_ids = {item["id"] for item in data} + assert check_ids == {"s3_bucket_public_access", "cloudtrail_enabled"} + assert all(item["attributes"]["severity"] == "critical" for item in data) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + @pytest.mark.parametrize( + "filter_key,filter_value", + [ + ("status", "INVALID_STATUS"), + ("severity", "INVALID_SEVERITY"), + ], + ) + def test_finding_groups_invalid_status_or_severity_returns_400( + self, + authenticated_client, + finding_groups_fixture, + endpoint_name, + filter_key, + filter_value, + ): + """Test invalid aggregated status/severity values are rejected.""" + params = {f"filter[{filter_key}]": filter_value} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["errors"][0]["code"] == "invalid" + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + @pytest.mark.parametrize( + "filter_key,filter_value,expected_detail", + [ + ("status__in", "FAIL,INVALID_STATUS", "invalid status filter"), + ("severity__in", "critical,INVALID_SEVERITY", "invalid severity filter"), + ], + ) + def test_finding_groups_invalid_in_filters_return_400( + self, + authenticated_client, + finding_groups_fixture, + endpoint_name, + filter_key, + filter_value, + expected_detail, + ): + """Test invalid values in status__in/severity__in are rejected.""" + params = {f"filter[{filter_key}]": filter_value} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + errors = response.json()["errors"] + assert errors[0]["code"] == "invalid" + assert expected_detail in errors[0]["detail"] + + @pytest.mark.parametrize( + "filter_name,filter_value", + [ + ("region", "__region_does_not_exist__"), + ("service", "__service_does_not_exist__"), + ("category", "__category_does_not_exist__"), + ("resource_groups", "__group_does_not_exist__"), + ("resource_type", "__type_does_not_exist__"), + ("scan", "00000000-0000-7000-8000-000000000001"), + ], + ) + def test_finding_groups_finding_level_filters_are_applied( + self, + authenticated_client, + finding_groups_fixture, + filter_name, + filter_value, + ): + """Test finding-level filters are applied in /finding-groups aggregation.""" + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, f"filter[{filter_name}]": filter_value}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 0 + + def test_finding_groups_delta_filter_is_applied( + self, authenticated_client, finding_groups_fixture + ): + """Test delta filter is applied in /finding-groups aggregation.""" + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[delta]": "new"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + assert all(item["attributes"]["new_count"] > 0 for item in data) + def test_finding_groups_provider_aggregation( self, authenticated_client, finding_groups_fixture ): @@ -15127,6 +15929,66 @@ class TestFindingGroupViewSet: assert len(response.json()["data"]) == 1 assert "bucket" in response.json()["data"][0]["id"].lower() + def test_finding_groups_check_title_icontains( + self, authenticated_client, finding_groups_fixture + ): + """Test searching check titles with icontains.""" + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[check_title.icontains]": "public access", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == "s3_bucket_public_access" + + @pytest.mark.parametrize( + "extra_filters", + [ + {}, + {"filter[muted]": "include"}, + ], + ids=["summary_path", "finding_level_path"], + ) + def test_check_title_icontains_includes_all_title_variants( + self, + authenticated_client, + finding_groups_title_variants_fixture, + extra_filters, + ): + """ + Regression: two providers report the same check_id with different + checktitle values (e.g. after a Prowler version upgrade). Filtering + by check_title__icontains with a term that matches only ONE variant + must still return the finding group with counts from BOTH providers. + + Parametrized to cover both aggregation paths: + - summary_path: default, uses _CheckTitleToCheckIdMixin on summaries + - finding_level_path: filter[muted]=include forces CommonFindingFilters + """ + params = { + "filter[inserted_at]": TODAY, + "filter[check_title.icontains]": "Ensure repository", + **extra_filters, + } + response = authenticated_client.get( + reverse("finding-group-list"), + params, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert data[0]["id"] == "github_secret_scanning_enabled" + attrs = data[0]["attributes"] + # Both providers' findings must be counted + assert attrs["fail_count"] == 2, ( + "fail_count must include findings from both providers, " + "regardless of which title variant matches the search" + ) + def test_resources_not_found(self, authenticated_client): """Test 404 returned for nonexistent check_id.""" response = authenticated_client.get( @@ -15168,6 +16030,44 @@ class TestFindingGroupViewSet: assert resource.get("region"), "resource.region must not be empty" assert resource.get("type"), "resource.type must not be empty" + def test_resources_resource_group( + self, authenticated_client, finding_groups_fixture + ): + """Test resource_group is extracted from check_metadata.resourcegroup.""" + response = authenticated_client.get( + reverse( + "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"} + ), + {"filter[inserted_at]": TODAY}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + for item in data: + resource = item["attributes"]["resource"] + assert ( + resource["resource_group"] == "storage" + ), "resource_group must be 'storage'" + + def test_resources_name_icontains( + self, authenticated_client, finding_groups_fixture + ): + """Test resource_name__icontains filters resources by name substring.""" + # s3_bucket_public_access has "My Instance 1" and "My Instance 2" + response = authenticated_client.get( + reverse( + "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"} + ), + { + "filter[inserted_at]": TODAY, + "filter[resource_name.icontains]": "Instance 1", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + assert "Instance 1" in data[0]["attributes"]["resource"]["name"] + def test_resources_provider_info( self, authenticated_client, finding_groups_fixture ): @@ -15246,6 +16146,191 @@ class TestFindingGroupViewSet: # Should still return the 2 resources within the date range assert len(response.json()["data"]) == 2 + def test_resources_status_filter_returns_empty_not_404( + self, authenticated_client, finding_groups_fixture + ): + """Test that filtering by status on a valid check returns empty list, not 404.""" + # s3_bucket_public_access has only FAIL findings, filtering by PASS should return [] + response = authenticated_client.get( + reverse( + "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"} + ), + {"filter[inserted_at]": TODAY, "filter[status]": "PASS"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + + def test_resources_nonexistent_check_still_404( + self, authenticated_client, finding_groups_fixture + ): + """Test that a truly nonexistent check_id still returns 404.""" + response = authenticated_client.get( + reverse("finding-group-resources", kwargs={"pk": "totally_fake_check"}), + {"filter[inserted_at]": TODAY}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_resources_sort_by_status_ascending( + self, authenticated_client, finding_groups_fixture + ): + """Test sort=status returns PASS before FAIL.""" + # ec2_instance_public_ip has 1 PASS (resource1) and 1 FAIL (resource2) + response = authenticated_client.get( + reverse( + "finding-group-resources", + kwargs={"pk": "ec2_instance_public_ip"}, + ), + {"filter[inserted_at]": TODAY, "sort": "status"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + assert data[0]["attributes"]["status"] == "PASS" + assert data[1]["attributes"]["status"] == "FAIL" + + def test_resources_sort_by_status_descending( + self, authenticated_client, finding_groups_fixture + ): + """Test sort=-status returns FAIL before PASS.""" + response = authenticated_client.get( + reverse( + "finding-group-resources", + kwargs={"pk": "ec2_instance_public_ip"}, + ), + {"filter[inserted_at]": TODAY, "sort": "-status"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + assert data[0]["attributes"]["status"] == "FAIL" + assert data[1]["attributes"]["status"] == "PASS" + + def test_resources_sort_invalid_field_returns_400( + self, authenticated_client, finding_groups_fixture + ): + """Test that an invalid sort field returns 400.""" + response = authenticated_client.get( + reverse( + "finding-group-resources", kwargs={"pk": "s3_bucket_public_access"} + ), + {"filter[inserted_at]": TODAY, "sort": "invalid_field"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_latest_resources_status_filter_returns_empty_not_404( + self, authenticated_client, finding_groups_fixture + ): + """Test latest resources with status filter on valid check returns empty, not 404.""" + response = authenticated_client.get( + reverse( + "finding-group-latest_resources", + kwargs={"check_id": "s3_bucket_public_access"}, + ), + {"filter[status]": "PASS"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + + def test_latest_resources_sort_by_status( + self, authenticated_client, finding_groups_fixture + ): + """Test latest resources sort=status returns PASS before FAIL.""" + response = authenticated_client.get( + reverse( + "finding-group-latest_resources", + kwargs={"check_id": "ec2_instance_public_ip"}, + ), + {"sort": "status"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 2 + assert data[0]["attributes"]["status"] == "PASS" + assert data[1]["attributes"]["status"] == "FAIL" + + def test_resources_nonexistent_check_missing_date_returns_400( + self, authenticated_client, finding_groups_fixture + ): + """Nonexistent check_id with missing required date filter returns 400, not 404.""" + response = authenticated_client.get( + reverse("finding-group-resources", kwargs={"pk": "totally_fake_check"}), + ) + # FindingGroupFilter requires inserted_at — validation fires before existence check + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_resources_nonexistent_check_invalid_sort_returns_400( + self, authenticated_client, finding_groups_fixture + ): + """Nonexistent check_id with invalid sort returns 400, not 404.""" + response = authenticated_client.get( + reverse("finding-group-resources", kwargs={"pk": "totally_fake_check"}), + {"filter[inserted_at]": TODAY, "sort": "invalid_field"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_resources_empty_sort_falls_back_to_default_order( + self, authenticated_client, finding_groups_fixture + ): + """Degenerate sort values should behave like no sort, not raise 500.""" + all_ids = set() + for page_num in (1, 2): + response = authenticated_client.get( + reverse( + "finding-group-resources", + kwargs={"pk": "s3_bucket_public_access"}, + ), + { + "filter[inserted_at]": TODAY, + "sort": ",", + "page[size]": 1, + "page[number]": page_num, + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + all_ids.add(data[0]["id"]) + assert len(all_ids) == 2 + + def test_resources_sort_pagination_stability( + self, authenticated_client, finding_groups_fixture + ): + """Sort with small page size returns all resources without duplicates or gaps.""" + # s3_bucket_public_access has 2 resources, both FAIL — they tie on status + all_ids = set() + for page_num in (1, 2): + response = authenticated_client.get( + reverse( + "finding-group-resources", + kwargs={"pk": "s3_bucket_public_access"}, + ), + { + "filter[inserted_at]": TODAY, + "sort": "status", + "page[size]": 1, + "page[number]": page_num, + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + all_ids.add(data[0]["id"]) + # Both pages should return different resources (no duplicates) + assert len(all_ids) == 2 + + def test_latest_resources_nonexistent_check_invalid_sort_returns_400( + self, authenticated_client, finding_groups_fixture + ): + """Nonexistent check_id with invalid sort on latest returns 400, not 404.""" + response = authenticated_client.get( + reverse( + "finding-group-latest_resources", + kwargs={"check_id": "totally_fake_check"}, + ), + {"sort": "invalid_field"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + # Test provider_id filter actually filters data def test_finding_groups_provider_id_filter_actually_filters( self, authenticated_client, finding_groups_fixture, providers_fixture @@ -15425,6 +16510,258 @@ class TestFindingGroupViewSet: assert len(data) == 1 assert data[0]["id"] == "cloudtrail_enabled" + def test_finding_groups_latest_status_filter( + self, authenticated_client, finding_groups_fixture + ): + """Test /latest supports status filter on aggregated status.""" + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[status]": "FAIL"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + assert all(item["attributes"]["status"] == "FAIL" for item in data) + + def test_finding_groups_latest_region_filter_reaggregates_metrics( + self, authenticated_client, finding_groups_fixture + ): + """Test /latest recomputes metrics from findings matching region filter.""" + response = authenticated_client.get( + reverse("finding-group-latest"), + { + "filter[check_id]": "ec2_instance_public_ip", + "filter[region]": "us-east-1", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + + attrs = data[0]["attributes"] + assert attrs["status"] == "PASS" + assert attrs["pass_count"] == 1 + assert attrs["fail_count"] == 0 + assert attrs["resources_total"] == 1 + assert attrs["resources_fail"] == 0 + + def test_finding_groups_latest_status_in_filter( + self, authenticated_client, finding_groups_fixture + ): + """Test /latest supports status__in filter on aggregated status.""" + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[status__in]": "FAIL,PASS"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + assert all(item["attributes"]["status"] in {"FAIL", "PASS"} for item in data) + + def test_finding_groups_latest_severity_filter( + self, authenticated_client, finding_groups_fixture + ): + """Test /latest supports severity filter on aggregated severity.""" + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[severity]": "critical"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + assert all(item["attributes"]["severity"] == "critical" for item in data) + + @pytest.mark.parametrize( + "filter_name,filter_value", + [ + ("region", "__region_does_not_exist__"), + ("service", "__service_does_not_exist__"), + ("category", "__category_does_not_exist__"), + ("resource_groups", "__group_does_not_exist__"), + ("resource_type", "__type_does_not_exist__"), + ("scan", "00000000-0000-7000-8000-000000000001"), + ], + ) + def test_finding_groups_latest_finding_level_filters_are_applied( + self, + authenticated_client, + finding_groups_fixture, + filter_name, + filter_value, + ): + """Test finding-level filters are applied in /finding-groups/latest aggregation.""" + response = authenticated_client.get( + reverse("finding-group-latest"), + {f"filter[{filter_name}]": filter_value}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 0 + + def test_finding_groups_check_title_filter_applies_with_delta( + self, authenticated_client, finding_groups_fixture + ): + """Test check_title filter is honored when finding-level path is used.""" + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[delta]": "new", + "filter[check_title.icontains]": "__missing_check_title__", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 0 + + def test_finding_groups_latest_check_title_filter_applies_with_delta( + self, authenticated_client, finding_groups_fixture + ): + """Test /latest check_title filter is honored on finding-level path.""" + response = authenticated_client.get( + reverse("finding-group-latest"), + { + "filter[delta]": "new", + "filter[check_title.icontains]": "__missing_check_title__", + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 0 + + def test_finding_groups_latest_delta_filter_is_applied( + self, authenticated_client, finding_groups_fixture + ): + """Test delta filter is applied in /finding-groups/latest aggregation.""" + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[delta]": "new"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + assert all(item["attributes"]["new_count"] > 0 for item in data) + + def test_finding_groups_latest_aggregates_latest_per_provider( + self, + authenticated_client, + providers_fixture, + resources_fixture, + ): + """Test /latest keeps all findings from the latest scan per provider. + + Verifies that when the latest scan produces multiple findings for the + same check_id (e.g. one per resource), all of them are included in the + aggregation — not just one. + """ + provider1 = providers_fixture[0] + provider2 = providers_fixture[1] + resource1 = resources_fixture[0] + resource2 = resources_fixture[1] + resource3 = resources_fixture[2] + check_id = "cross_provider_latest_resources_total" + + latest_scan_provider1 = Scan.objects.create( + tenant_id=provider1.tenant_id, + provider=provider1, + state=StateChoices.COMPLETED, + trigger=Scan.TriggerChoices.MANUAL, + completed_at=datetime.now(timezone.utc), + ) + + latest_scan_provider2 = Scan.objects.create( + tenant_id=provider2.tenant_id, + provider=provider2, + state=StateChoices.COMPLETED, + trigger=Scan.TriggerChoices.MANUAL, + completed_at=datetime.now(timezone.utc), + ) + + older_scan_provider1 = Scan.objects.create( + tenant_id=provider1.tenant_id, + provider=provider1, + state=StateChoices.COMPLETED, + trigger=Scan.TriggerChoices.MANUAL, + completed_at=datetime.now(timezone.utc) - timedelta(days=1), + ) + + # Older scan — these should be excluded from /latest + Finding.objects.create( + tenant_id=provider1.tenant_id, + uid="old_cross_provider_1", + scan=older_scan_provider1, + delta="new", + status="FAIL", + severity="high", + impact="high", + check_id=check_id, + check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"}, + first_seen_at=datetime.now(timezone.utc) - timedelta(days=2), + muted=False, + ) + + # Latest scan provider1: TWO findings (PASS + FAIL) for the same check + latest_p1_pass = Finding.objects.create( + tenant_id=provider1.tenant_id, + uid="latest_cross_provider_1_pass", + scan=latest_scan_provider1, + delta="new", + status="PASS", + severity="high", + impact="high", + check_id=check_id, + check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"}, + first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1), + muted=False, + ) + latest_p1_pass.add_resources([resource1]) + + latest_p1_fail = Finding.objects.create( + tenant_id=provider1.tenant_id, + uid="latest_cross_provider_1_fail", + scan=latest_scan_provider1, + delta="new", + status="FAIL", + severity="high", + impact="high", + check_id=check_id, + check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"}, + first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1), + muted=False, + ) + latest_p1_fail.add_resources([resource2]) + + # Latest scan provider2: one finding + latest_p2 = Finding.objects.create( + tenant_id=provider2.tenant_id, + uid="latest_cross_provider_2", + scan=latest_scan_provider2, + delta="new", + status="FAIL", + severity="high", + impact="high", + check_id=check_id, + check_metadata={"CheckId": check_id, "checktitle": "Cross provider check"}, + first_seen_at=datetime.now(timezone.utc) - timedelta(hours=1), + muted=False, + ) + latest_p2.add_resources([resource3]) + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[check_id]": check_id, "filter[delta]": "new"}, + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + attrs = data[0]["attributes"] + # 3 findings total: 2 from provider1 latest + 1 from provider2 latest + assert attrs["pass_count"] == 1 + assert attrs["fail_count"] == 2 + assert attrs["resources_total"] == 3 + assert attrs["resources_fail"] == 2 + def test_finding_groups_latest_provider_type_filter( self, authenticated_client, finding_groups_fixture ): @@ -15464,6 +16801,44 @@ class TestFindingGroupViewSet: check_ids = [item["id"] for item in data] assert check_ids == sorted(check_ids) + def test_finding_groups_latest_sort_by_check_title( + self, authenticated_client, finding_groups_fixture + ): + """Test /latest supports sorting by check_title.""" + response = authenticated_client.get( + reverse("finding-group-latest"), + {"sort": "check_title"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + check_titles = [item["attributes"]["check_title"] for item in data] + assert check_titles == sorted(check_titles) + + @pytest.mark.parametrize( + "endpoint_name", ["finding-group-list", "finding-group-latest"] + ) + @pytest.mark.parametrize( + "sort_field", + ["first_seen_at", "-first_seen_at", "last_seen_at", "failing_since"], + ) + def test_finding_groups_sort_by_time_fields( + self, + authenticated_client, + finding_groups_fixture, + endpoint_name, + sort_field, + ): + """Test sorting by aggregated time fields (first_seen_at, last_seen_at, failing_since).""" + params = {"sort": sort_field} + if endpoint_name == "finding-group-list": + params["filter[inserted_at]"] = TODAY + + response = authenticated_client.get(reverse(endpoint_name), params) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + def test_finding_groups_latest_ignores_date_filters( self, authenticated_client, finding_groups_fixture ): diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index d856bd5e6a..b80d54b08a 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -27,6 +27,9 @@ if TYPE_CHECKING: from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider from prowler.providers.gcp.gcp_provider import GcpProvider from prowler.providers.github.github_provider import GithubProvider + from prowler.providers.googleworkspace.googleworkspace_provider import ( + GoogleworkspaceProvider, + ) from prowler.providers.iac.iac_provider import IacProvider from prowler.providers.image.image_provider import ImageProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider @@ -36,6 +39,7 @@ if TYPE_CHECKING: ) from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider + from prowler.providers.vercel.vercel_provider import VercelProvider class CustomOAuth2Client(OAuth2Client): @@ -83,6 +87,7 @@ def return_prowler_provider( | CloudflareProvider | GcpProvider | GithubProvider + | GoogleworkspaceProvider | IacProvider | ImageProvider | KubernetesProvider @@ -90,6 +95,7 @@ def return_prowler_provider( | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider + | VercelProvider ): """Return the Prowler provider class based on the given provider type. @@ -97,7 +103,7 @@ def return_prowler_provider( provider (Provider): The provider object containing the provider type and associated secrets. Returns: - AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: The corresponding provider class. + AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | GoogleworkspaceProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: The corresponding provider class. Raises: ValueError: If the provider type specified in `provider.provider` is not supported. @@ -111,6 +117,12 @@ def return_prowler_provider( from prowler.providers.gcp.gcp_provider import GcpProvider prowler_provider = GcpProvider + case Provider.ProviderChoices.GOOGLEWORKSPACE.value: + from prowler.providers.googleworkspace.googleworkspace_provider import ( + GoogleworkspaceProvider, + ) + + prowler_provider = GoogleworkspaceProvider case Provider.ProviderChoices.AZURE.value: from prowler.providers.azure.azure_provider import AzureProvider @@ -165,6 +177,10 @@ def return_prowler_provider( from prowler.providers.image.image_provider import ImageProvider prowler_provider = ImageProvider + case Provider.ProviderChoices.VERCEL.value: + from prowler.providers.vercel.vercel_provider import VercelProvider + + prowler_provider = VercelProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -225,6 +241,11 @@ def get_prowler_provider_kwargs( # clouds_yaml_content, clouds_yaml_cloud and provider_id are validated # in the provider itself, so it's not needed here. pass + elif provider.provider == Provider.ProviderChoices.VERCEL.value: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "team_id": provider.uid, + } elif provider.provider == Provider.ProviderChoices.IMAGE.value: # Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or # a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest"). @@ -263,6 +284,7 @@ def initialize_prowler_provider( | CloudflareProvider | GcpProvider | GithubProvider + | GoogleworkspaceProvider | IacProvider | ImageProvider | KubernetesProvider @@ -270,6 +292,7 @@ def initialize_prowler_provider( | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider + | VercelProvider ): """Initialize a Prowler provider instance based on the given provider type. @@ -278,7 +301,7 @@ def initialize_prowler_provider( mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration. Returns: - AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: An instance of the corresponding provider class + AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | GoogleworkspaceProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: An instance of the corresponding provider class initialized with the provider's secrets. """ prowler_provider = return_prowler_provider(provider) @@ -321,6 +344,13 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: "raise_on_exception": False, } return prowler_provider.test_connection(**openstack_kwargs) + elif provider.provider == Provider.ProviderChoices.VERCEL.value: + vercel_kwargs = { + **prowler_provider_kwargs, + "team_id": provider.uid, + "raise_on_exception": False, + } + return prowler_provider.test_connection(**vercel_kwargs) elif provider.provider == Provider.ProviderChoices.IMAGE.value: image_kwargs = { "image": provider.uid, @@ -404,8 +434,12 @@ def prowler_integration_connection_test(integration: Integration) -> Connection: raise_on_exception=False, ) project_keys = jira_connection.projects if jira_connection.is_connected else {} + issue_types = ( + jira_connection.issue_types if jira_connection.is_connected else {} + ) with rls_transaction(str(integration.tenant_id)): integration.configuration["projects"] = project_keys + integration.configuration["issue_types"] = issue_types integration.save() return jira_connection elif integration.integration_type == Integration.IntegrationChoices.SLACK: diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py index b389085886..aaa0f4aa31 100644 --- a/api/src/backend/api/v1/serializer_utils/integrations.py +++ b/api/src/backend/api/v1/serializer_utils/integrations.py @@ -69,8 +69,10 @@ class SecurityHubConfigSerializer(BaseValidateSerializer): class JiraConfigSerializer(BaseValidateSerializer): domain = serializers.CharField(read_only=True) - issue_types = serializers.ListField( - read_only=True, child=serializers.CharField(), default=["Task"] + issue_types = serializers.DictField( + read_only=True, + child=serializers.ListField(child=serializers.CharField()), + default={}, ) projects = serializers.DictField(read_only=True) diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 83ea942793..f8d67f0e3b 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -191,6 +191,22 @@ from rest_framework_json_api import serializers }, "required": ["service_account_key"], }, + { + "type": "object", + "title": "Google Workspace Service Account", + "properties": { + "credentials_content": { + "type": "string", + "description": "The service account JSON credentials content for Google Workspace API access with domain-wide delegation enabled.", + }, + "delegated_user": { + "type": "string", + "format": "email", + "description": "The email address of the Google Workspace super admin user to impersonate for domain-wide delegation.", + }, + }, + "required": ["credentials_content", "delegated_user"], + }, { "type": "object", "title": "Kubernetes Static Credentials", @@ -388,6 +404,17 @@ from rest_framework_json_api import serializers }, "required": ["clouds_yaml_content", "clouds_yaml_cloud"], }, + { + "type": "object", + "title": "Vercel API Token", + "properties": { + "api_token": { + "type": "string", + "description": "Vercel API token for authentication. Can be scoped to a specific team.", + }, + }, + "required": ["api_token"], + }, ] } ) diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 7c4bb41ca5..185a8e047a 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -6,6 +6,7 @@ from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth.models import update_last_login from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import IntegrityError from drf_spectacular.utils import extend_schema_field from jwt.exceptions import InvalidKeyError @@ -959,6 +960,26 @@ class ProviderCreateSerializer(RLSSerializer, BaseWriteSerializer): }, } + def create(self, validated_data): + try: + return super().create(validated_data) + except DjangoValidationError as e: + if "unique_provider_uids" in str(e): + raise ConflictException( + detail="Provider already exists.", + pointer="/data/attributes/uid", + ) + raise + except IntegrityError as e: + # Handle race conditions where the unique constraint is enforced at the DB level + # after validation has already passed. + if "unique_provider_uids" in str(e): + raise ConflictException( + detail="Provider already exists.", + pointer="/data/attributes/uid", + ) + raise + class ProviderUpdateSerializer(BaseWriteSerializer): """ @@ -1220,7 +1241,7 @@ class AttackPathsQueryRunRequestSerializer(BaseSerializerV1): class AttackPathsCustomQueryRunRequestSerializer(BaseSerializerV1): - query = serializers.CharField() + query = serializers.CharField(max_length=10000, min_length=1, trim_whitespace=True) class JSONAPIMeta: resource_name = "attack-paths-custom-query-run-requests" @@ -1520,6 +1541,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): serializer = AzureProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GCP.value: serializer = GCPProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.GOOGLEWORKSPACE.value: + serializer = GoogleWorkspaceProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.GITHUB.value: serializer = GithubProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.IAC.value: @@ -1550,6 +1573,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): serializer = OpenStackCloudsYamlProviderSecret(data=secret) elif provider_type == Provider.ProviderChoices.IMAGE.value: serializer = ImageProviderSecret(data=secret) + elif provider_type == Provider.ProviderChoices.VERCEL.value: + serializer = VercelProviderSecret(data=secret) else: raise serializers.ValidationError( {"provider": f"Provider type not supported {provider_type}"} @@ -1655,6 +1680,14 @@ class GCPServiceAccountProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class GoogleWorkspaceProviderSecret(serializers.Serializer): + credentials_content = serializers.CharField() + delegated_user = serializers.EmailField() + + class Meta: + resource_name = "provider-secrets" + + class MongoDBAtlasProviderSecret(serializers.Serializer): atlas_public_key = serializers.CharField() atlas_private_key = serializers.CharField() @@ -1748,6 +1781,13 @@ class ImageProviderSecret(serializers.Serializer): return attrs +class VercelProviderSecret(serializers.Serializer): + api_token = serializers.CharField() + + class Meta: + resource_name = "provider-secrets" + + class AlibabaCloudProviderSecret(serializers.Serializer): access_key_id = serializers.CharField() access_key_secret = serializers.CharField() @@ -2682,11 +2722,11 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer): ) config_serializer = JiraConfigSerializer # Create non-editable configuration for JIRA integration - default_jira_issue_types = ["Task"] + # issue_types will be populated per project when connection is tested configuration.update( { "projects": {}, - "issue_types": default_jira_issue_types, + "issue_types": {}, "domain": credentials.get("domain"), } ) @@ -2901,13 +2941,25 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer): return representation +class IntegrationJiraIssueTypesSerializer(BaseSerializerV1): + """ + Serializer for Jira issue types response. + """ + + project_key = serializers.CharField(read_only=True) + issue_types = serializers.ListField(child=serializers.CharField(), read_only=True) + + class JSONAPIMeta: + resource_name = "jira-issue-types" + + class IntegrationJiraDispatchSerializer(BaseSerializerV1): """ Serializer for dispatching findings to JIRA integration. """ project_key = serializers.CharField(required=True) - issue_type = serializers.ChoiceField(required=True, choices=["Task"]) + issue_type = serializers.CharField(required=True) class JSONAPIMeta: resource_name = "integrations-jira-dispatches" @@ -2936,6 +2988,23 @@ class IntegrationJiraDispatchSerializer(BaseSerializerV1): } ) + issue_type = attrs.get("issue_type") + available_issue_types = integration_instance.configuration.get( + "issue_types", {} + ) + # Handle old format where issue_types was a flat list (e.g., ["Task"]) + if not isinstance(available_issue_types, dict): + available_issue_types = {} + project_issue_types = available_issue_types.get(project_key, []) + if project_issue_types and issue_type not in project_issue_types: + raise ValidationError( + { + "issue_type": f"The issue type '{issue_type}' is not available for project '{project_key}'. " + f"Available types: {', '.join(project_issue_types)}. " + "Refresh the connection if this is an error." + } + ) + return validated_attrs @@ -4149,6 +4218,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1): severity = serializers.CharField() first_seen_at = serializers.DateTimeField(required=False, allow_null=True) last_seen_at = serializers.DateTimeField(required=False, allow_null=True) + muted_reason = serializers.CharField(required=False, allow_null=True) class JSONAPIMeta: resource_name = "finding-group-resources" @@ -4162,6 +4232,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1): "service": {"type": "string"}, "region": {"type": "string"}, "type": {"type": "string"}, + "resource_group": {"type": "string"}, }, } ) @@ -4173,6 +4244,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1): "service": obj.get("resource_service", ""), "region": obj.get("resource_region", ""), "type": obj.get("resource_type", ""), + "resource_group": obj.get("resource_group", ""), } @extend_schema_field( diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index bbe5d08167..533106d0e4 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -51,6 +51,13 @@ from api.v1.views import ( ) +# This helper view is used to block any endpoints that should not be available +# To use it, add a new entry in the `urlpatterns` list, for example (old but real one): +# path( +# "attack-paths-scans//queries/custom", +# _blocked_endpoint, +# name="attack-paths-scans-queries-custom-blocked", +# ), @csrf_exempt def _blocked_endpoint(request, *args, **kwargs): return JsonResponse( @@ -209,17 +216,6 @@ urlpatterns = [ path("tokens/saml", SAMLTokenValidateView.as_view(), name="token-saml"), path("tokens/google", GoogleSocialLoginView.as_view(), name="token-google"), path("tokens/github", GithubSocialLoginView.as_view(), name="token-github"), - # TODO: Remove these blocked endpoints once they are properly tested - path( - "attack-paths-scans//queries/custom", - _blocked_endpoint, - name="attack-paths-scans-queries-custom-blocked", - ), - path( - "attack-paths-scans//schema", - _blocked_endpoint, - name="attack-paths-scans-schema-blocked", - ), path("", include(router.urls)), path("", include(tenants_router.urls)), path("", include(users_router.urls)), diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 12b8077ea8..32c7834274 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -3,7 +3,7 @@ import glob import json import logging import os - +import time from collections import defaultdict from copy import deepcopy from datetime import datetime, timedelta, timezone @@ -11,12 +11,12 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from urllib.parse import urljoin import sentry_sdk - from allauth.socialaccount.models import SocialAccount, SocialApp from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError +from celery import chain from celery.result import AsyncResult from config.custom_logging import BackendLogger from config.env import env @@ -31,6 +31,7 @@ from django.contrib.postgres.search import SearchQuery from django.db import transaction from django.db.models import ( Case, + CharField, Count, DecimalField, ExpressionWrapper, @@ -47,7 +48,8 @@ from django.db.models import ( When, Window, ) -from django.db.models.functions import Coalesce, RowNumber +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Cast, Coalesce, RowNumber from django.http import HttpResponse, QueryDict from django.shortcuts import redirect from django.urls import reverse @@ -75,6 +77,7 @@ from rest_framework.exceptions import ( ) from rest_framework.generics import GenericAPIView, get_object_or_404 from rest_framework.permissions import SAFE_METHODS +from rest_framework_json_api import filters as jsonapi_filters from rest_framework_json_api.views import RelationshipView, Response from rest_framework_simplejwt.exceptions import InvalidToken, TokenError from tasks.beat import schedule_provider_scan @@ -92,6 +95,7 @@ from tasks.tasks import ( jira_integration_task, mute_historical_findings_task, perform_scan_task, + reaggregate_all_finding_group_summaries_task, refresh_lighthouse_provider_models_task, ) @@ -99,7 +103,6 @@ from api.attack_paths import database as graph_database from api.attack_paths import get_queries_for_provider, get_query_by_id from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset -from api.renderers import APIJSONRenderer, PlainTextRenderer from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, @@ -123,6 +126,7 @@ from api.filters import ( CustomDjangoFilterBackend, DailySeveritySummaryFilter, FindingFilter, + FindingGroupAggregatedComputedFilter, FindingGroupFilter, FindingGroupSummaryFilter, IntegrationFilter, @@ -198,10 +202,12 @@ from api.models import ( ) from api.pagination import ComplianceOverviewPagination from api.rbac.permissions import Permissions, get_providers, get_role +from api.renderers import APIJSONRenderer, PlainTextRenderer from api.rls import Tenant from api.utils import ( CustomOAuth2Client, get_findings_metadata_no_aggregations, + initialize_prowler_integration, initialize_prowler_provider, validate_invitation, ) @@ -230,6 +236,7 @@ from api.v1.serializers import ( FindingsSeverityOverTimeSerializer, IntegrationCreateSerializer, IntegrationJiraDispatchSerializer, + IntegrationJiraIssueTypesSerializer, IntegrationSerializer, IntegrationUpdateSerializer, InvitationAcceptSerializer, @@ -407,7 +414,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.20.0" + spectacular_settings.VERSION = "1.24.0" spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) @@ -942,7 +949,12 @@ class UserViewSet(BaseUserViewset): def get_serializer_context(self): context = super().get_serializer_context() if self.request.user.is_authenticated: - context["role"] = get_role(self.request.user) + tenant_id = getattr(self.request, "tenant_id", None) + if tenant_id: + try: + context["role"] = get_role(self.request.user, tenant_id) + except PermissionDenied: + context["role"] = None return context @action(detail=False, methods=["get"], url_name="me") @@ -1206,6 +1218,17 @@ class TenantViewSet(BaseTenantViewset): # RBAC required permissions required_permissions = [Permissions.MANAGE_ACCOUNT] + def set_required_permissions(self): + """ + Returns the required permissions based on the request method. + """ + if self.action in ("list", "retrieve", "create"): + # No permissions required for listing, retrieving or creating tenants + self.required_permissions = [] + else: + # Require MANAGE_ACCOUNT for update and delete + self.required_permissions = [Permissions.MANAGE_ACCOUNT] + def get_queryset(self): queryset = Tenant.objects.filter(membership__user=self.request.user) return queryset.prefetch_related("memberships") @@ -1213,28 +1236,44 @@ class TenantViewSet(BaseTenantViewset): def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - tenant = serializer.save() - Membership.objects.create( + tenant = Tenant.objects.using(MainRouter.admin_db).create( + **serializer.validated_data + ) + Membership.objects.using(MainRouter.admin_db).create( user=self.request.user, tenant=tenant, role=Membership.RoleChoices.OWNER ) + serializer.instance = tenant return Response(data=serializer.data, status=status.HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): - # This will perform validation and raise a 404 if the tenant does not exist - tenant_id = kwargs.get("pk") - get_object_or_404(Tenant, id=tenant_id) + tenant = self.get_object() + tenant_id = str(tenant.id) + + # Only owners can delete a tenant + membership = Membership.objects.filter(user=request.user, tenant=tenant).first() + if not membership or membership.role != Membership.RoleChoices.OWNER: + raise PermissionDenied("Only owners can delete a tenant.") with transaction.atomic(): - # Delete memberships + # Collect user IDs from this tenant's memberships before deleting them + tenant_user_ids = set( + Membership.objects.using(MainRouter.admin_db) + .filter(tenant_id=tenant_id) + .values_list("user_id", flat=True) + ) + + # Delete memberships for this tenant Membership.objects.using(MainRouter.admin_db).filter( tenant_id=tenant_id ).delete() - # Delete users without memberships - User.objects.using(MainRouter.admin_db).filter( - membership__isnull=True - ).delete() - # Delete tenant in batches + # Delete only users that were exclusively in this tenant + if tenant_user_ids: + User.objects.using(MainRouter.admin_db).filter( + id__in=tenant_user_ids, membership__isnull=True + ).delete() + + # Delete tenant data in background delete_tenant_task.apply_async(kwargs={"tenant_id": tenant_id}) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1299,8 +1338,12 @@ class TenantMembersViewSet(BaseTenantViewset): http_method_names = ["get", "delete"] serializer_class = MembershipSerializer queryset = Membership.objects.none() - # RBAC required permissions - required_permissions = [Permissions.MANAGE_ACCOUNT] + # Authorization is handled by get_requesting_membership (owner/member checks), + # not by RBAC, since the target tenant differs from the JWT tenant. + required_permissions = [] + + def set_required_permissions(self): + self.required_permissions = [] def get_queryset(self): tenant = self.get_tenant() @@ -1313,8 +1356,10 @@ class TenantMembersViewSet(BaseTenantViewset): def get_tenant(self): tenant_id = self.kwargs.get("tenant_pk") - tenant = get_object_or_404(Tenant, id=tenant_id) - return tenant + return get_object_or_404( + Tenant.objects.filter(membership__user=self.request.user), + id=tenant_id, + ) def get_requesting_membership(self, tenant): try: @@ -1401,7 +1446,7 @@ class ProviderGroupViewSet(BaseRLSViewSet): self.required_permissions = [Permissions.MANAGE_PROVIDERS] def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) # Check if any of the user's roles have UNLIMITED_VISIBILITY if user_roles.unlimited_visibility: # User has unlimited visibility, return all provider groups @@ -1570,7 +1615,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): self.required_permissions = [Permissions.MANAGE_PROVIDERS] def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all providers queryset = Provider.objects.filter(tenant_id=self.request.tenant_id) @@ -1825,7 +1870,7 @@ class ScanViewSet(BaseRLSViewSet): self.required_permissions = [Permissions.MANAGE_SCANS] def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all scans queryset = Scan.objects.filter(tenant_id=self.request.tenant_id) @@ -2451,6 +2496,11 @@ class AttackPathsScanViewSet(BaseRLSViewSet): # RBAC required permissions required_permissions = [Permissions.MANAGE_SCANS] + def get_throttles(self): + if self.action == "run_custom_attack_paths_query": + self.throttle_scope = "attack-paths-custom-query" + return super().get_throttles() + def set_required_permissions(self): if self.request.method in SAFE_METHODS: self.required_permissions = [] @@ -2471,7 +2521,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet): return super().get_serializer_class() def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id) if user_roles.unlimited_visibility: @@ -2570,13 +2620,33 @@ class AttackPathsScanViewSet(BaseRLSViewSet): provider_id, ) + start = time.monotonic() graph = attack_paths_views_helpers.execute_query( database_name, query_definition, parameters, provider_id, ) - graph_database.clear_cache(database_name) + query_duration = time.monotonic() - start + + result_nodes = len(graph.get("nodes", [])) + result_relationships = len(graph.get("relationships", [])) + logger.info( + "attack_paths_query_run", + extra={ + "user_id": str(request.user.id), + "tenant_id": str(attack_paths_scan.provider.tenant_id), + "metadata": { + "query_id": query_definition.id, + "provider": query_definition.provider, + "scan_id": pk, + "provider_id": provider_id, + "result_nodes": result_nodes, + "result_relationships": result_relationships, + "query_duration": round(query_duration, 3), + }, + }, + ) status_code = status.HTTP_200_OK if not graph.get("nodes"): @@ -2618,12 +2688,33 @@ class AttackPathsScanViewSet(BaseRLSViewSet): ) provider_id = str(attack_paths_scan.provider_id) + start = time.monotonic() graph = attack_paths_views_helpers.execute_custom_query( database_name, serializer.validated_data["query"], provider_id, ) - graph_database.clear_cache(database_name) + query_duration = time.monotonic() - start + + query_length = len(serializer.validated_data["query"]) + result_nodes = len(graph.get("nodes", [])) + result_relationships = len(graph.get("relationships", [])) + logger.info( + "attack_paths_custom_query_run", + extra={ + "user_id": str(request.user.id), + "tenant_id": str(attack_paths_scan.provider.tenant_id), + "metadata": { + "provider": attack_paths_scan.provider.provider, + "scan_id": pk, + "provider_id": provider_id, + "query_length": query_length, + "result_nodes": result_nodes, + "result_relationships": result_relationships, + "query_duration": round(query_duration, 3), + }, + }, + ) status_code = status.HTTP_200_OK if not graph.get("nodes"): @@ -2765,7 +2856,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): required_permissions = [] def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all scans queryset = Resource.all_objects.filter(tenant_id=self.request.tenant_id) @@ -3387,7 +3478,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): def get_queryset(self): tenant_id = self.request.tenant_id - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all findings queryset = Finding.all_objects.filter(tenant_id=tenant_id) @@ -3419,7 +3510,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): request, filtered_queryset, manager=Finding.all_objects, - select_related=["scan"], + select_related=["scan__provider"], prefetch_related=["resources"], ) @@ -3589,7 +3680,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): tenant_id = request.tenant_id filtered_queryset = self.filter_queryset(self.get_queryset()) - latest_scan_ids = ( + latest_scan_ids = list( Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) .order_by("provider_id", "-inserted_at") .distinct("provider_id") @@ -3603,7 +3694,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): request, filtered_queryset, manager=Finding.all_objects, - select_related=["scan"], + select_related=["scan__provider"], prefetch_related=["resources"], ) @@ -3990,9 +4081,9 @@ class RoleViewSet(BaseRLSViewSet): ) ) def partial_update(self, request, *args, **kwargs): - user_role = get_role(request.user) + user_role = get_role(request.user, request.tenant_id) # If the user is the owner of the role, the manage_account field is not editable - if user_role and kwargs["pk"] == str(user_role.id): + if kwargs["pk"] == str(user_role.id): request.data["manage_account"] = str(user_role.manage_account).lower() return super().partial_update(request, *args, **kwargs) @@ -4248,7 +4339,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): required_permissions = [] def get_queryset(self): - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) unlimited_visibility = getattr( role, Permissions.UNLIMITED_VISIBILITY.value, False ) @@ -4290,7 +4381,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): def _compliance_summaries_queryset(self, scan_id): """Return pre-aggregated summaries constrained by RBAC visibility.""" - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) unlimited_visibility = getattr( role, Permissions.UNLIMITED_VISIBILITY.value, False ) @@ -4832,7 +4923,7 @@ class OverviewViewSet(BaseRLSViewSet): required_permissions = [] def get_queryset(self): - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) providers = get_providers(role) if not role.unlimited_visibility: @@ -6005,7 +6096,7 @@ class IntegrationViewSet(BaseRLSViewSet): allowed_providers = None def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all integrations queryset = Integration.objects.filter(tenant_id=self.request.tenant_id) @@ -6060,7 +6151,15 @@ class IntegrationViewSet(BaseRLSViewSet): tags=["Integration"], summary="Send findings to a Jira integration", description="Send a set of filtered findings to the given integration. At least one finding filter must be " - "provided.", + "provided.\n\n" + "## Known Limitations\n\n" + "### Issue Types with Required Custom Fields\n\n" + "Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not " + "currently populate when creating work items. If a selected issue type enforces required fields beyond " + 'the standard set (e.g., "Team", "Epic Name"), the work item creation will fail.\n\n' + "To avoid this, select an issue type that does not require additional custom fields - **Task**, **Bug**, " + "or **Story** typically work without restrictions. If unsure which issue types are available for a project, " + 'Prowler automatically fetches and displays them in the "Issue Type" selector when sending a finding.', responses={202: OpenApiResponse(response=TaskSerializer)}, filters=True, ) @@ -6068,7 +6167,7 @@ class IntegrationViewSet(BaseRLSViewSet): class IntegrationJiraViewSet(BaseRLSViewSet): queryset = Finding.all_objects.all() serializer_class = IntegrationJiraDispatchSerializer - http_method_names = ["post"] + http_method_names = ["get", "post"] filter_backends = [CustomDjangoFilterBackend] filterset_class = IntegrationJiraFindingsFilter # RBAC required permissions @@ -6078,9 +6177,27 @@ class IntegrationJiraViewSet(BaseRLSViewSet): def create(self, request, *args, **kwargs): raise MethodNotAllowed(method="POST") + @extend_schema(exclude=True) + def list(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + + @extend_schema(exclude=True) + def retrieve(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + + def get_serializer_class(self): + if self.action == "issue_types": + return IntegrationJiraIssueTypesSerializer + return super().get_serializer_class() + + def get_filter_backends(self): + if self.action == "issue_types": + return [] + return super().get_filter_backends() + def get_queryset(self): tenant_id = self.request.tenant_id - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all findings queryset = Finding.all_objects.filter(tenant_id=tenant_id) @@ -6092,6 +6209,65 @@ class IntegrationJiraViewSet(BaseRLSViewSet): return queryset + @extend_schema( + tags=["Integration"], + summary="Get available issue types for a Jira project", + description="Fetch the available issue types from Jira for a given project key and update the integration configuration.", + parameters=[ + OpenApiParameter( + name="project_key", + type=str, + location=OpenApiParameter.QUERY, + required=True, + description="The Jira project key to fetch issue types for.", + ), + ], + ) + @action(detail=False, methods=["get"], url_name="issue-types") + def issue_types(self, request, integration_pk=None): + integration = get_object_or_404(Integration, pk=integration_pk) + + project_key = request.query_params.get("project_key") + if not project_key: + raise ValidationError({"project_key": "This query parameter is required."}) + + projects = integration.configuration.get("projects", {}) + if project_key not in projects: + raise ValidationError( + { + "project_key": "The given project key is not available for this JIRA integration." + } + ) + + try: + jira = initialize_prowler_integration(integration) + fetched_issue_types = jira.get_available_issue_types(project_key) + except Exception as e: + logger.error( + f"Failed to fetch issue types from Jira for integration {integration_pk}, " + f"project {project_key}: {e}" + ) + raise ValidationError( + { + "issue_types": "Failed to fetch issue types from Jira. Please check the integration connection." + } + ) + + # Update the integration configuration with the fetched issue types + issue_types_config = integration.configuration.get("issue_types", {}) + if not isinstance(issue_types_config, dict): + issue_types_config = {} + issue_types_config[project_key] = fetched_issue_types + + with rls_transaction(str(integration.tenant_id), using="default"): + integration.configuration["issue_types"] = issue_types_config + integration.save(using="default") + + serializer = IntegrationJiraIssueTypesSerializer( + {"project_key": project_key, "issue_types": fetched_issue_types} + ) + return Response(data=serializer.data, status=status.HTTP_200_OK) + @action(detail=False, methods=["post"], url_name="dispatches") def dispatches(self, request, integration_pk=None): get_object_or_404(Integration, pk=integration_pk) @@ -6676,11 +6852,18 @@ class MuteRuleViewSet(BaseRLSViewSet): muted_reason=mute_rule.reason, ) - # Launch background task for historical muting - with transaction.atomic(): - mute_historical_findings_task.apply_async( - kwargs={"tenant_id": tenant_id, "mute_rule_id": str(mute_rule.id)} - ) + # Launch background task for historical muting + reaggregation + transaction.on_commit( + lambda: chain( + mute_historical_findings_task.si( + tenant_id=tenant_id, + mute_rule_id=str(mute_rule.id), + ), + reaggregate_all_finding_group_summaries_task.si( + tenant_id=tenant_id, + ), + ).apply_async() + ) # Return the created mute rule serializer = self.get_serializer(mute_rule) @@ -6721,26 +6904,42 @@ class FindingGroupViewSet(BaseRLSViewSet): security analysts to see which checks are failing across their infrastructure without scrolling through thousands of individual findings. - Uses pre-aggregated FindingGroupDailySummary table for efficient queries. - Daily summaries are re-aggregated across the requested date range. + Uses a hybrid strategy: pre-aggregated daily summaries when possible, + and raw findings when finding-level filters require precise subset metrics. """ queryset = FindingGroupDailySummary.objects.all() serializer_class = FindingGroupSerializer - filterset_class = FindingGroupSummaryFilter + filterset_class = FindingGroupFilter + filter_backends = [ + jsonapi_filters.QueryParameterValidationFilter, + jsonapi_filters.OrderingFilter, + CustomDjangoFilterBackend, + ] http_method_names = ["get"] required_permissions = [] def get_filterset_class(self): - """Return appropriate filter based on action.""" + """Return the filterset class used for schema generation and the list action. + + Note: The resources and latest_resources actions do not use this method + at runtime. They manually instantiate FindingGroupFilter / + LatestFindingGroupFilter against a Finding queryset (see + _get_finding_queryset). The class returned here for those actions only + affects the OpenAPI schema generated by drf-spectacular. + """ if self.action == "latest": - return LatestFindingGroupSummaryFilter - return FindingGroupSummaryFilter + return LatestFindingGroupFilter + if self.action == "resources": + return FindingGroupFilter + if self.action == "latest_resources": + return LatestFindingGroupFilter + return FindingGroupFilter def get_queryset(self): """Get the base FindingGroupDailySummary queryset with RLS filtering.""" tenant_id = self.request.tenant_id - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) queryset = FindingGroupDailySummary.objects.filter(tenant_id=tenant_id) if not role.unlimited_visibility: @@ -6750,7 +6949,7 @@ class FindingGroupViewSet(BaseRLSViewSet): def _get_finding_queryset(self): """Get the Finding queryset for resources drill-down (with RBAC).""" - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) providers = get_providers(role) tenant_id = self.request.tenant_id @@ -6791,8 +6990,15 @@ class FindingGroupViewSet(BaseRLSViewSet): "resource_type__icontains": "type__icontains", } + # Fields accepted directly by LatestResourceFilter (no translation needed) + _RESOURCE_FILTER_FIELDS = { + f"{field}__{lookup}" + for field, lookups in LatestResourceFilter.Meta.fields.items() + for lookup in lookups + } | set(LatestResourceFilter.Meta.fields.keys()) + def _split_resource_filters(self, params: QueryDict) -> tuple[QueryDict, QueryDict]: - resource_keys = set(self.RESOURCE_FILTER_MAP) + resource_keys = set(self.RESOURCE_FILTER_MAP) | self._RESOURCE_FILTER_FIELDS finding_params = QueryDict(mutable=True) resource_params = QueryDict(mutable=True) for key, values in params.lists(): @@ -6813,11 +7019,16 @@ class FindingGroupViewSet(BaseRLSViewSet): queryset = queryset.filter(tenant_id=tenant_id) filter_params = QueryDict(mutable=True) - for key, mapped_key in self.RESOURCE_FILTER_MAP.items(): - if key not in params: + for key, values in params.lists(): + # Translate resource_* prefixed keys via the map + if key in self.RESOURCE_FILTER_MAP: + mapped_key = self.RESOURCE_FILTER_MAP[key] + elif key in self._RESOURCE_FILTER_FIELDS: + mapped_key = key + else: continue + if key == "resources" or key.endswith("__in"): - values = params.getlist(key) items: list[str] = [] for value in values: if value is None: @@ -6842,20 +7053,27 @@ class FindingGroupViewSet(BaseRLSViewSet): return filterset.qs.values("id") + def _get_finding_level_filter_keys(self, latest: bool = False) -> set[str]: + """Derive filters that require querying raw findings.""" + summary_filterset = ( + LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter + ) + finding_filterset = LatestFindingGroupFilter if latest else FindingGroupFilter + + summary_supported = set(summary_filterset.base_filters.keys()) + finding_supported = set(finding_filterset.base_filters.keys()) + return finding_supported - summary_supported + + def _requires_finding_level_aggregation( + self, params: QueryDict, latest: bool = False + ) -> bool: + finding_level_keys = self._get_finding_level_filter_keys(latest=latest) + return any(key in finding_level_keys for key in params.keys()) + def _aggregate_daily_summaries(self, queryset): - """ - Re-aggregate daily summaries across the date range. - - Takes pre-computed daily summaries and aggregates them by check_id - to produce totals across the selected date range. - """ - from django.db.models import CharField - from django.db.models.functions import Cast - + """Re-aggregate summary rows by check_id.""" return queryset.values("check_id").annotate( - # Max severity across days severity_order=Max("severity_order"), - # Sum counts across days pass_count=Sum("pass_count"), fail_count=Sum("fail_count"), muted_count=Sum("muted_count"), @@ -6863,22 +7081,99 @@ class FindingGroupViewSet(BaseRLSViewSet): changed_count=Sum("changed_count"), resources_total=Sum("resources_total"), resources_fail=Sum("resources_fail"), - # Collect provider types using StringAgg (cast enum to text first) impacted_providers_str=StringAgg( Cast("provider__provider", CharField()), delimiter=",", distinct=True, default="", ), - # Min/Max timing across days - first_seen_at=Min("first_seen_at"), - last_seen_at=Max("last_seen_at"), - failing_since=Min("failing_since"), - # Get check metadata from first row (same for all days) + agg_first_seen_at=Min("first_seen_at"), + agg_last_seen_at=Max("last_seen_at"), + agg_failing_since=Min("failing_since"), check_title=Max("check_title"), check_description=Max("check_description"), ) + def _aggregate_findings(self, queryset): + """Aggregate findings by check_id for finding-group endpoints.""" + severity_case = Case( + *[ + When(severity=severity, then=Value(order)) + for severity, order in SEVERITY_ORDER.items() + ], + output_field=IntegerField(), + ) + + return queryset.values("check_id").annotate( + severity_order=Max(severity_case), + pass_count=Count("id", filter=Q(status="PASS", muted=False)), + fail_count=Count("id", filter=Q(status="FAIL", muted=False)), + muted_count=Count("id", filter=Q(muted=True)), + new_count=Count("id", filter=Q(delta="new", muted=False)), + changed_count=Count("id", filter=Q(delta="changed", muted=False)), + resources_total=Count("resources__id", distinct=True), + resources_fail=Count( + "resources__id", + distinct=True, + filter=Q(status="FAIL", muted=False), + ), + impacted_providers_str=StringAgg( + Cast("scan__provider__provider", CharField()), + delimiter=",", + distinct=True, + default="", + ), + agg_first_seen_at=Min("first_seen_at"), + agg_last_seen_at=Max("inserted_at"), + agg_failing_since=Min( + "first_seen_at", filter=Q(status="FAIL", muted=False) + ), + check_title=Coalesce( + Max(KeyTextTransform("checktitle", "check_metadata")), + Max(KeyTextTransform("CheckTitle", "check_metadata")), + Max(KeyTextTransform("Checktitle", "check_metadata")), + ), + check_description=Coalesce( + Max(KeyTextTransform("description", "check_metadata")), + Max(KeyTextTransform("Description", "check_metadata")), + ), + ) + + def _split_computed_aggregate_filters( + self, params: QueryDict + ) -> tuple[QueryDict, QueryDict]: + """Split finding filters from computed aggregate filters.""" + computed_keys = { + "status", + "status__in", + "severity", + "severity__in", + "include_muted", + } + finding_params = QueryDict(mutable=True) + computed_params = QueryDict(mutable=True) + + for key, values in params.lists(): + if key in computed_keys: + computed_params.setlist(key, values) + else: + finding_params.setlist(key, values) + + return finding_params, computed_params + + def _get_latest_findings_per_provider(self, filtered_queryset): + """Keep only findings from each provider's most recent completed scan.""" + latest_scan_ids = ( + Scan.objects.filter( + tenant_id=self.request.tenant_id, + state=StateChoices.COMPLETED, + ) + .order_by("provider_id", "-completed_at", "-inserted_at") + .distinct("provider_id") + .values("id") + ) + return filtered_queryset.filter(scan_id__in=latest_scan_ids) + def _post_process_aggregation(self, aggregated_data): """ Post-process aggregation results to add computed fields. @@ -6895,6 +7190,13 @@ class FindingGroupViewSet(BaseRLSViewSet): severity_order, "informational" ) + if "agg_first_seen_at" in row: + row["first_seen_at"] = row.pop("agg_first_seen_at") + if "agg_last_seen_at" in row: + row["last_seen_at"] = row.pop("agg_last_seen_at") + if "agg_failing_since" in row: + row["failing_since"] = row.pop("agg_failing_since") + # Compute aggregated status if row.get("fail_count", 0) > 0: row["status"] = "FAIL" @@ -6913,22 +7215,40 @@ class FindingGroupViewSet(BaseRLSViewSet): return results - def _validate_sort_fields(self, sort_param): - """Validate and map JSON:API sort fields for aggregated finding groups.""" - sort_field_map = { - "check_id": "check_id", - "severity": "severity_order", - "fail_count": "fail_count", - "pass_count": "pass_count", - "muted_count": "muted_count", - "new_count": "new_count", - "changed_count": "changed_count", - "resources_total": "resources_total", - "resources_fail": "resources_fail", - "first_seen_at": "first_seen_at", - "last_seen_at": "last_seen_at", - "failing_since": "failing_since", - } + _FINDING_GROUP_SORT_MAP = { + "check_id": "check_id", + "check_title": "check_title", + "severity": "severity_order", + "fail_count": "fail_count", + "pass_count": "pass_count", + "muted_count": "muted_count", + "new_count": "new_count", + "changed_count": "changed_count", + "resources_total": "resources_total", + "resources_fail": "resources_fail", + "first_seen_at": "agg_first_seen_at", + "last_seen_at": "agg_last_seen_at", + "failing_since": "agg_failing_since", + } + + _RESOURCE_SORT_MAP = { + "status": "status_order", + "severity": "severity_order", + "first_seen_at": "first_seen_at", + "last_seen_at": "last_seen_at", + "resource.uid": "resource_uid", + "resource.name": "resource_name", + "resource.region": "resource_region", + "resource.service": "resource_service", + "resource.type": "resource_type", + "provider.uid": "provider_uid", + "provider.alias": "provider_alias", + } + + def _validate_sort_fields(self, sort_param, sort_field_map=None): + """Validate and map JSON:API sort fields using the given field map.""" + if sort_field_map is None: + sort_field_map = self._FINDING_GROUP_SORT_MAP ordering = [] for field in sort_param.split(","): @@ -6938,7 +7258,6 @@ class FindingGroupViewSet(BaseRLSViewSet): is_desc = field.startswith("-") raw_field = field[1:] if is_desc else field if raw_field not in sort_field_map: - # Validate sort fields explicitly to return JSON:API 400 instead of FieldError. raise ValidationError( [ { @@ -6954,6 +7273,33 @@ class FindingGroupViewSet(BaseRLSViewSet): return ordering + def _apply_aggregated_computed_filters(self, queryset, computed_params: QueryDict): + """Apply computed filters (status/severity) on aggregated finding-group rows.""" + if not computed_params: + return queryset + + if computed_params.get("status") or computed_params.getlist("status__in"): + queryset = queryset.annotate( + aggregated_status=Case( + When(fail_count__gt=0, then=Value("FAIL")), + When(pass_count__gt=0, then=Value("PASS")), + default=Value("MUTED"), + output_field=CharField(), + ) + ) + + # Exclude fully-muted groups by default unless include_muted is set + if "include_muted" not in computed_params: + queryset = queryset.exclude(fail_count=0, pass_count=0, muted_count__gt=0) + + filterset = FindingGroupAggregatedComputedFilter( + computed_params, queryset=queryset + ) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + + return filterset.qs + def _build_resource_mapping_queryset( self, filtered_queryset, resource_ids=None, tenant_id: str | None = None ): @@ -7026,9 +7372,67 @@ class FindingGroupViewSet(BaseRLSViewSet): ), first_seen_at=Min("finding__first_seen_at"), last_seen_at=Max("finding__inserted_at"), + # Max() on muted_reason / check_metadata is safe because + # all findings for the same resource+check share identical + # values (mute rules and metadata are applied per-check). + muted_reason=Max("finding__muted_reason"), + resource_group=Max( + KeyTextTransform("resourcegroup", "finding__check_metadata") + ), ) .filter(resource_id__isnull=False) - .order_by("resource_id") + ) + + # Annotations needed for each sort field (lightweight versions for ordering) + _RESOURCE_SORT_ANNOTATIONS = { + "status_order": lambda: Max( + Case( + When(finding__status="FAIL", finding__muted=False, then=Value(3)), + When(finding__status="PASS", finding__muted=False, then=Value(2)), + default=Value(1), + output_field=IntegerField(), + ) + ), + "severity_order": lambda: Max( + Case( + *[ + When(finding__severity=severity, then=Value(order)) + for severity, order in SEVERITY_ORDER.items() + ], + output_field=IntegerField(), + ) + ), + "first_seen_at": lambda: Min("finding__first_seen_at"), + "last_seen_at": lambda: Max("finding__inserted_at"), + "resource_uid": lambda: Max("resource__uid"), + "resource_name": lambda: Max("resource__name"), + "resource_region": lambda: Max("resource__region"), + "resource_service": lambda: Max("resource__service"), + "resource_type": lambda: Max("resource__type"), + "provider_uid": lambda: Max("resource__provider__uid"), + "provider_alias": lambda: Max("resource__provider__alias"), + } + + def _build_resource_ordering_queryset( + self, filtered_queryset, resource_ids, tenant_id, ordering + ): + """Build a lightweight aggregation with only the columns needed for sorting.""" + mapping_qs = self._build_resource_mapping_queryset( + filtered_queryset, resource_ids=resource_ids, tenant_id=tenant_id + ) + + # Collect only the annotations required by the requested ordering + annotations = {} + for field in ordering: + col = field.lstrip("-") + if col != "resource_id" and col in self._RESOURCE_SORT_ANNOTATIONS: + annotations[col] = self._RESOURCE_SORT_ANNOTATIONS[col]() + + return ( + mapping_qs.values("resource_id") + .annotate(**annotations) + .filter(resource_id__isnull=False) + .order_by(*ordering) ) def _post_process_resources(self, resource_data): @@ -7061,56 +7465,183 @@ class FindingGroupViewSet(BaseRLSViewSet): ), "first_seen_at": row["first_seen_at"], "last_seen_at": row["last_seen_at"], + "muted_reason": row.get("muted_reason"), + "resource_group": row.get("resource_group", ""), } ) return results + def _build_aggregated_queryset(self, finding_params, latest=False): + """Select the summary or findings path and return an aggregated queryset.""" + finding_filterset_class = ( + LatestFindingGroupFilter if latest else FindingGroupFilter + ) + summary_filterset_class = ( + LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter + ) + + if self._requires_finding_level_aggregation(finding_params, latest=latest): + finding_queryset = self._get_finding_queryset() + filterset = finding_filterset_class( + finding_params, queryset=finding_queryset + ) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + filtered_queryset = filterset.qs + if latest: + filtered_queryset = self._get_latest_findings_per_provider( + filtered_queryset + ) + return self._aggregate_findings(filtered_queryset) + + summary_queryset = self.get_queryset() + filterset = summary_filterset_class(finding_params, queryset=summary_queryset) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + filtered_queryset = filterset.qs + # Only include summaries from each provider's most recent date + # (within the filtered range). + # We use a subquery to strip the Window annotation so it does not + # leak into the GROUP BY of _aggregate_daily_summaries. + latest_per_provider = filtered_queryset.annotate( + _max_provider_date=Window( + expression=Max("inserted_at"), + partition_by=[F("provider_id")], + ), + ).filter(inserted_at=F("_max_provider_date")) + clean_queryset = FindingGroupDailySummary.objects.filter( + pk__in=latest_per_provider.values("pk") + ) + return self._aggregate_daily_summaries(clean_queryset) + + def _sorted_paginated_response( + self, + request, + aggregated_queryset, + ): + """Apply ordering, pagination, post-processing, and return the Response.""" + sort_param = request.query_params.get("sort") + if sort_param: + ordering = self._validate_sort_fields( + sort_param, self._FINDING_GROUP_SORT_MAP + ) + if ordering: + aggregated_queryset = aggregated_queryset.order_by(*ordering) + else: + aggregated_queryset = aggregated_queryset.order_by( + "-fail_count", "-severity_order", "check_id" + ) + + page = self.paginate_queryset(aggregated_queryset) + if page is not None: + processed_data = self._post_process_aggregation(page) + serializer = self.get_serializer(processed_data, many=True) + return self.get_paginated_response(serializer.data) + + processed_data = self._post_process_aggregation(aggregated_queryset) + serializer = self.get_serializer(processed_data, many=True) + return Response(serializer.data) + + def _validate_resource_sort(self, request): + """Validate the sort parameter for resource endpoints (raises 400 if invalid).""" + sort_param = request.query_params.get("sort") + if sort_param: + self._validate_sort_fields(sort_param, self._RESOURCE_SORT_MAP) + + def _paginated_resource_response( + self, request, filtered_queryset, resource_ids, tenant_id + ): + """Paginate and return resources. + + Without sort: paginate lightweight resource IDs first, aggregate only the page. + With sort: build a lightweight ordering subquery (resource_id + sort keys), + paginate that, then aggregate full details only for the page. + """ + sort_param = request.query_params.get("sort") + + if sort_param: + ordering = self._validate_sort_fields(sort_param, self._RESOURCE_SORT_MAP) + if ordering: + if "resource_id" not in {field.lstrip("-") for field in ordering}: + ordering.append("resource_id") + + # Phase 1: lightweight aggregation with only sort keys, paginate + ordering_qs = self._build_resource_ordering_queryset( + filtered_queryset, + resource_ids=resource_ids, + tenant_id=tenant_id, + ordering=ordering, + ) + page = self.paginate_queryset(ordering_qs) + if page is not None: + page_ids = [row["resource_id"] for row in page] + resource_data = self._build_resource_aggregation( + filtered_queryset, resource_ids=page_ids, tenant_id=tenant_id + ) + # Re-sort to match the page ordering + id_order = {rid: idx for idx, rid in enumerate(page_ids)} + results = self._post_process_resources(resource_data) + results.sort(key=lambda r: id_order.get(r["resource_id"], 0)) + serializer = FindingGroupResourceSerializer(results, many=True) + return self.get_paginated_response(serializer.data) + + page_ids = [row["resource_id"] for row in ordering_qs] + resource_data = self._build_resource_aggregation( + filtered_queryset, resource_ids=page_ids, tenant_id=tenant_id + ) + id_order = {rid: idx for idx, rid in enumerate(page_ids)} + results = self._post_process_resources(resource_data) + results.sort(key=lambda r: id_order.get(r["resource_id"], 0)) + serializer = FindingGroupResourceSerializer(results, many=True) + return Response(serializer.data) + + # No sort (or only empty sort fragments): paginate lightweight resource IDs + # first, aggregate only the page. + mapping_qs = self._build_resource_mapping_queryset( + filtered_queryset, resource_ids=resource_ids, tenant_id=tenant_id + ) + resource_id_qs = ( + mapping_qs.values_list("resource_id", flat=True) + .distinct() + .order_by("resource_id") + ) + + page_ids = self.paginate_queryset(resource_id_qs) + if page_ids is not None: + resource_data = self._build_resource_aggregation( + filtered_queryset, resource_ids=page_ids, tenant_id=tenant_id + ) + id_order = {rid: idx for idx, rid in enumerate(page_ids)} + results = self._post_process_resources(resource_data) + results.sort(key=lambda r: id_order.get(r["resource_id"], 0)) + serializer = FindingGroupResourceSerializer(results, many=True) + return self.get_paginated_response(serializer.data) + + resource_data = self._build_resource_aggregation( + filtered_queryset, resource_ids=resource_ids, tenant_id=tenant_id + ).order_by("resource_id") + results = self._post_process_resources(resource_data) + serializer = FindingGroupResourceSerializer(results, many=True) + return Response(serializer.data) + def list(self, request, *args, **kwargs): """ List finding groups with aggregation and filtering. Returns findings grouped by check_id with aggregated metrics. Requires at least one date filter for performance. - Uses pre-aggregated daily summaries for efficient queries. + Uses summaries when possible and raw findings for finding-level filters. """ - queryset = self.get_queryset() - - # Apply filters normalized_params = self._normalize_jsonapi_params(request.query_params) - filterset = self.filterset_class(normalized_params, queryset=queryset) - if not filterset.is_valid(): - raise ValidationError(filterset.errors) - filtered_queryset = filterset.qs - - # Re-aggregate daily summaries across the date range - aggregated_queryset = self._aggregate_daily_summaries(filtered_queryset) - - # Apply ordering (respect JSON:API sort param or use default) - sort_param = request.query_params.get("sort") - if sort_param: - # Convert JSON:API sort notation (prefix '-' for descending) - ordering = self._validate_sort_fields(sort_param) - if ordering: - aggregated_queryset = aggregated_queryset.order_by(*ordering) - else: - # Default ordering: failures first, then severity, then check_id - aggregated_queryset = aggregated_queryset.order_by( - "-fail_count", "-severity_order", "check_id" - ) - - # Paginate - page = self.paginate_queryset(aggregated_queryset) - if page is not None: - # Post-process the page - processed_data = self._post_process_aggregation(page) - serializer = self.get_serializer(processed_data, many=True) - return self.get_paginated_response(serializer.data) - - # Post-process all results (no pagination) - processed_data = self._post_process_aggregation(aggregated_queryset) - serializer = self.get_serializer(processed_data, many=True) - return Response(serializer.data) + finding_params, computed_params = self._split_computed_aggregate_filters( + normalized_params + ) + aggregated_qs = self._build_aggregated_queryset(finding_params, latest=False) + aggregated_qs = self._apply_aggregated_computed_filters( + aggregated_qs, computed_params + ) + return self._sorted_paginated_response(request, aggregated_qs) @extend_schema( summary="List latest finding groups", @@ -7128,56 +7659,22 @@ class FindingGroupViewSet(BaseRLSViewSet): """ List the latest finding group state per check_id. - Returns findings grouped by check_id using the latest available - inserted_at date per check_id, without requiring date filters. + Returns findings grouped by check_id using latest data per + (check_id, provider), without requiring date filters. """ - queryset = self.get_queryset() - - # Apply other filters (provider_id, provider_type, check_id, etc.) normalized_params = self._normalize_jsonapi_params(request.query_params) - # Remove date filters since we're using latest for key in list(normalized_params.keys()): if key.startswith("inserted_at"): del normalized_params[key] - filterset_class = self.get_filterset_class() - filterset = filterset_class(normalized_params, queryset=queryset) - if not filterset.is_valid(): - raise ValidationError(filterset.errors) - filtered_queryset = filterset.qs - - # Keep only rows from the latest inserted_at date per check_id - latest_per_check = filtered_queryset.annotate( - latest_inserted_at=Window( - expression=Max("inserted_at"), - partition_by=[F("check_id")], - ) - ).filter(inserted_at=F("latest_inserted_at")) - - # Re-aggregate daily summaries - aggregated_queryset = self._aggregate_daily_summaries(latest_per_check) - - # Apply ordering - sort_param = request.query_params.get("sort") - if sort_param: - ordering = self._validate_sort_fields(sort_param) - if ordering: - aggregated_queryset = aggregated_queryset.order_by(*ordering) - else: - aggregated_queryset = aggregated_queryset.order_by( - "-fail_count", "-severity_order", "check_id" - ) - - # Paginate - page = self.paginate_queryset(aggregated_queryset) - if page is not None: - processed_data = self._post_process_aggregation(page) - serializer = self.get_serializer(processed_data, many=True) - return self.get_paginated_response(serializer.data) - - processed_data = self._post_process_aggregation(aggregated_queryset) - serializer = self.get_serializer(processed_data, many=True) - return Response(serializer.data) + finding_params, computed_params = self._split_computed_aggregate_filters( + normalized_params + ) + aggregated_qs = self._build_aggregated_queryset(finding_params, latest=True) + aggregated_qs = self._apply_aggregated_computed_filters( + aggregated_qs, computed_params + ) + return self._sorted_paginated_response(request, aggregated_qs) @extend_schema( summary="List resources for a finding group", @@ -7188,6 +7685,7 @@ class FindingGroupViewSet(BaseRLSViewSet): and timing information including how long they have been failing. """, tags=["Finding Groups"], + filters=True, ) @action(detail=True, methods=["get"], url_path="resources") def resources(self, request, pk=None): @@ -7200,57 +7698,33 @@ class FindingGroupViewSet(BaseRLSViewSet): check_id = pk queryset = self._get_finding_queryset() - # Apply date filters from request to Finding queryset + # 1. Normalize and split params normalized_params = self._normalize_jsonapi_params(request.query_params) finding_params, resource_params = self._split_resource_filters( normalized_params ) + # 2. Validate all inputs before any DB existence check filterset = FindingGroupFilter(finding_params, queryset=queryset) if not filterset.is_valid(): raise ValidationError(filterset.errors) + # Access .qs to trigger filter_queryset validation (e.g. required date filters) filtered_queryset = filterset.qs - - # Filter by check_id - filtered_queryset = filtered_queryset.filter(check_id=check_id) - - # Check if any findings exist for this check_id - if not filtered_queryset.exists(): - raise NotFound(f"Finding group '{check_id}' not found.") - resource_ids = self._resource_ids_from_params( resource_params, request.tenant_id ) - mapping_queryset = self._build_resource_mapping_queryset( - filtered_queryset, - resource_ids=resource_ids, - tenant_id=request.tenant_id, - ) - resource_id_queryset = ( - mapping_queryset.values_list("resource_id", flat=True) - .distinct() - .order_by("resource_id") - ) + self._validate_resource_sort(request) - page_ids = self.paginate_queryset(resource_id_queryset) - if page_ids is not None: - resource_data = self._build_resource_aggregation( - filtered_queryset, - resource_ids=page_ids, - tenant_id=request.tenant_id, - ) - results = self._post_process_resources(resource_data) - serializer = FindingGroupResourceSerializer(results, many=True) - return self.get_paginated_response(serializer.data) + # 3. Check if the finding group exists (scoped to tenant/RBAC, ignoring user filters) + if not queryset.filter(check_id=check_id).exists(): + raise NotFound(f"Finding group '{check_id}' not found.") - resource_data = self._build_resource_aggregation( - filtered_queryset, - resource_ids=resource_ids, - tenant_id=request.tenant_id, + # 4. Narrow to check_id + filtered_queryset = filtered_queryset.filter(check_id=check_id) + + return self._paginated_resource_response( + request, filtered_queryset, resource_ids, request.tenant_id ) - results = self._post_process_resources(resource_data) - serializer = FindingGroupResourceSerializer(results, many=True) - return Response(serializer.data) @extend_schema( summary="List resources for a finding group from latest scans", @@ -7262,6 +7736,7 @@ class FindingGroupViewSet(BaseRLSViewSet): and timing information. No date filters required. """, tags=["Finding Groups"], + filters=True, ) @action( detail=False, @@ -7293,55 +7768,31 @@ class FindingGroupViewSet(BaseRLSViewSet): if key.startswith("inserted_at"): del normalized_params[key] + # 1. Normalize and split params finding_params, resource_params = self._split_resource_filters( normalized_params ) + # 2. Validate all inputs before any DB existence check filterset = LatestFindingGroupFilter(finding_params, queryset=queryset) if not filterset.is_valid(): raise ValidationError(filterset.errors) filtered_queryset = filterset.qs + resource_ids = self._resource_ids_from_params( + resource_params, request.tenant_id + ) + self._validate_resource_sort(request) - # Filter to latest scans and check_id + # 3. Check if the finding group exists (scoped to tenant/RBAC + latest scans) + if not queryset.filter(scan_id__in=latest_scan_ids, check_id=check_id).exists(): + raise NotFound(f"Finding group '{check_id}' not found.") + + # 4. Narrow to latest scans + check_id filtered_queryset = filtered_queryset.filter( scan_id__in=latest_scan_ids, check_id=check_id, ) - # Check if any findings exist for this check_id - if not filtered_queryset.exists(): - raise NotFound(f"Finding group '{check_id}' not found.") - - resource_ids = self._resource_ids_from_params( - resource_params, request.tenant_id + return self._paginated_resource_response( + request, filtered_queryset, resource_ids, request.tenant_id ) - mapping_queryset = self._build_resource_mapping_queryset( - filtered_queryset, - resource_ids=resource_ids, - tenant_id=request.tenant_id, - ) - resource_id_queryset = ( - mapping_queryset.values_list("resource_id", flat=True) - .distinct() - .order_by("resource_id") - ) - - page_ids = self.paginate_queryset(resource_id_queryset) - if page_ids is not None: - resource_data = self._build_resource_aggregation( - filtered_queryset, - resource_ids=page_ids, - tenant_id=request.tenant_id, - ) - results = self._post_process_resources(resource_data) - serializer = FindingGroupResourceSerializer(results, many=True) - return self.get_paginated_response(serializer.data) - - resource_data = self._build_resource_aggregation( - filtered_queryset, - resource_ids=resource_ids, - tenant_id=request.tenant_id, - ) - results = self._post_process_resources(resource_data) - serializer = FindingGroupResourceSerializer(results, many=True) - return Response(serializer.data) diff --git a/api/src/backend/config/custom_logging.py b/api/src/backend/config/custom_logging.py index fb04930679..fe2a090ca6 100644 --- a/api/src/backend/config/custom_logging.py +++ b/api/src/backend/config/custom_logging.py @@ -2,6 +2,7 @@ import json import logging from enum import StrEnum + from config.env import env from django_guid.log_filters import CorrelationId @@ -62,6 +63,8 @@ class NDJSONFormatter(logging.Formatter): log_record["duration"] = record.duration if hasattr(record, "status_code"): log_record["status_code"] = record.status_code + if hasattr(record, "metadata"): + log_record["metadata"] = record.metadata if record.exc_info: log_record["exc_info"] = self.formatException(record.exc_info) @@ -107,6 +110,8 @@ class HumanReadableFormatter(logging.Formatter): log_components.append(f"done in {record.duration}s:") if hasattr(record, "status_code"): log_components.append(f"{record.status_code}") + if hasattr(record, "metadata"): + log_components.append(f"metadata={record.metadata}") if record.exc_info: log_components.append(self.formatException(record.exc_info)) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index c9e1b4750f..238825591f 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -113,8 +113,11 @@ REST_FRAMEWORK = { "rest_framework.throttling.ScopedRateThrottle", ], "DEFAULT_THROTTLE_RATES": { - "token-obtain": env("DJANGO_THROTTLE_TOKEN_OBTAIN", default=None), "dj_rest_auth": None, + "token-obtain": env("DJANGO_THROTTLE_TOKEN_OBTAIN", default=None), + "attack-paths-custom-query": env( + "DJANGO_THROTTLE_ATTACK_PATHS_CUSTOM_QUERY", default="10/min" + ), }, } @@ -296,3 +299,8 @@ DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000) # SAML requirement CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True + +# Attack Paths +ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( + "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 +) # 48h diff --git a/api/src/backend/config/django/production.py b/api/src/backend/config/django/production.py index b2769237fc..91bd50d0d1 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -3,6 +3,10 @@ from config.env import env DEBUG = env.bool("DJANGO_DEBUG", default=False) ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["localhost", "127.0.0.1"]) +CORS_ALLOWED_ORIGINS = env.list( + "DJANGO_CORS_ALLOWED_ORIGINS", + default=["http://localhost", "http://127.0.0.1"], +) # Database # TODO Use Django database routers https://docs.djangoproject.com/en/5.0/topics/db/multi-db/#automatic-database-routing diff --git a/api/src/backend/config/settings/celery.py b/api/src/backend/config/settings/celery.py index a2aba00007..b7030ebea4 100644 --- a/api/src/backend/config/settings/celery.py +++ b/api/src/backend/config/settings/celery.py @@ -1,10 +1,52 @@ +from urllib.parse import quote + from config.env import env +_VALID_SCHEMES = {"redis", "rediss"} + + +def _build_celery_broker_url( + scheme: str, + username: str, + password: str, + host: str, + port: str, + db: str, +) -> str: + if scheme not in _VALID_SCHEMES: + raise ValueError( + f"Invalid VALKEY_SCHEME '{scheme}'. Must be one of: {', '.join(sorted(_VALID_SCHEMES))}" + ) + + encoded_username = quote(username, safe="") if username else "" + encoded_password = quote(password, safe="") if password else "" + + auth = "" + if encoded_username and encoded_password: + auth = f"{encoded_username}:{encoded_password}@" + elif encoded_password: + auth = f":{encoded_password}@" + elif encoded_username: + auth = f"{encoded_username}@" + + return f"{scheme}://{auth}{host}:{port}/{db}" + + +VALKEY_SCHEME = env("VALKEY_SCHEME", default="redis") +VALKEY_USERNAME = env("VALKEY_USERNAME", default="") +VALKEY_PASSWORD = env("VALKEY_PASSWORD", default="") VALKEY_HOST = env("VALKEY_HOST", default="valkey") VALKEY_PORT = env("VALKEY_PORT", default="6379") VALKEY_DB = env("VALKEY_DB", default="0") -CELERY_BROKER_URL = f"redis://{VALKEY_HOST}:{VALKEY_PORT}/{VALKEY_DB}" +CELERY_BROKER_URL = _build_celery_broker_url( + VALKEY_SCHEME, + VALKEY_USERNAME, + VALKEY_PASSWORD, + VALKEY_HOST, + VALKEY_PORT, + VALKEY_DB, +) CELERY_RESULT_BACKEND = "django-db" CELERY_TASK_TRACK_STARTED = True diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index 6b8c554258..65c6277817 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -1,4 +1,5 @@ import sentry_sdk + from config.env import env IGNORED_EXCEPTIONS = [ @@ -85,8 +86,20 @@ def before_send(event, hint): # Ignore logs with the ignored_exceptions # https://docs.python.org/3/library/logging.html#logrecord-objects if "log_record" in hint: - log_msg = hint["log_record"].msg - log_lvl = hint["log_record"].levelno + log_record = hint["log_record"] + log_msg = log_record.getMessage() + log_lvl = log_record.levelno + + # The Neo4j driver logs transient connection errors (defunct + # connections, resets) at ERROR level via the `neo4j.io` logger. + # `RetryableSession` handles these with retries. If all retries + # are exhausted, the exception propagates and Sentry captures + # it as a normal exception event. + if ( + getattr(log_record, "name", "").startswith("neo4j.io") + and "defunct" in log_msg + ): + return None # Handle Error and Critical events and discard the rest if log_lvl <= 40 and any(ignored in log_msg for ignored in IGNORED_EXCEPTIONS): diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index 209292ffad..ce1f24f638 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -111,8 +111,9 @@ def disable_logging(): logging.disable(logging.CRITICAL) -@pytest.fixture(scope="session", autouse=True) -def create_test_user(django_db_setup, django_db_blocker): +@pytest.fixture(scope="session") +def _session_test_user(django_db_setup, django_db_blocker): + """Create the test user once per session. Internal; use create_test_user instead.""" with django_db_blocker.unblock(): user = User.objects.create_user( name="testing", @@ -122,6 +123,21 @@ def create_test_user(django_db_setup, django_db_blocker): return user +@pytest.fixture(autouse=True) +def create_test_user(_session_test_user, django_db_blocker): + """Re-create the session-scoped test user when a TransactionTestCase + has truncated the users table.""" + with django_db_blocker.unblock(): + if not User.objects.filter(pk=_session_test_user.pk).exists(): + User.objects.create_user( + id=_session_test_user.pk, + name="testing", + email=TEST_USER, + password=TEST_PASSWORD, + ) + return _session_test_user + + @pytest.fixture(scope="function") def create_test_user_rbac(django_db_setup, django_db_blocker, tenants_fixture): with django_db_blocker.unblock(): @@ -543,6 +559,18 @@ def providers_fixture(tenants_fixture): alias="openstack_testing", tenant_id=tenant.id, ) + provider12 = Provider.objects.create( + provider="googleworkspace", + uid="C12345678", + alias="googleworkspace_testing", + tenant_id=tenant.id, + ) + provider13 = Provider.objects.create( + provider="vercel", + uid="team_abcdef1234567890ab", + alias="vercel_testing", + tenant_id=tenant.id, + ) return ( provider1, @@ -556,6 +584,8 @@ def providers_fixture(tenants_fixture): provider9, provider10, provider11, + provider12, + provider13, ) @@ -2005,6 +2035,7 @@ def finding_groups_fixture( "CheckId": "s3_bucket_public_access", "checktitle": "Ensure S3 buckets do not allow public access", "Description": "S3 buckets should be configured to restrict public access.", + "resourcegroup": "storage", }, first_seen_at="2024-01-02T00:00:00Z", muted=False, @@ -2029,6 +2060,7 @@ def finding_groups_fixture( "CheckId": "s3_bucket_public_access", "checktitle": "Ensure S3 buckets do not allow public access", "Description": "S3 buckets should be configured to restrict public access.", + "resourcegroup": "storage", }, first_seen_at="2024-01-03T00:00:00Z", muted=False, @@ -2227,6 +2259,89 @@ def finding_groups_fixture( return findings +@pytest.fixture +def finding_groups_title_variants_fixture( + tenants_fixture, providers_fixture, scans_fixture, resources_fixture +): + """ + Two providers report the same check_id with different checktitle values. + + Simulates a Prowler version upgrade where the check title changed but the + check_id stayed the same. Used to verify that check_title__icontains + resolves to check_id first, so results include all providers regardless + of which title variant matches the search term. + """ + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + scan1, scan2, *_ = scans_fixture + resource1, resource2, *_ = resources_fixture + + findings = [] + + # Provider 1 — OLD title variant + finding_old = Finding.objects.create( + tenant_id=tenant.id, + uid="fg_title_variant_old", + scan=scan1, + delta="new", + status=Status.FAIL, + status_extended="Secret scanning not enabled", + impact=Severity.high, + impact_extended="High risk", + severity=Severity.high, + raw_result={"status": Status.FAIL, "severity": Severity.high}, + tags={}, + check_id="github_secret_scanning_enabled", + check_metadata={ + "CheckId": "github_secret_scanning_enabled", + "checktitle": "Ensure repository has secret scanning enabled", + "Description": "Checks if secret scanning is enabled.", + }, + first_seen_at="2024-01-01T00:00:00Z", + muted=False, + ) + finding_old.add_resources([resource1]) + findings.append(finding_old) + + # Provider 2 — NEW title variant (same check_id, different checktitle) + finding_new = Finding.objects.create( + tenant_id=tenant.id, + uid="fg_title_variant_new", + scan=scan2, + delta="new", + status=Status.FAIL, + status_extended="Secret scanning not enabled on repo", + impact=Severity.high, + impact_extended="High risk", + severity=Severity.high, + raw_result={"status": Status.FAIL, "severity": Severity.high}, + tags={}, + check_id="github_secret_scanning_enabled", + check_metadata={ + "CheckId": "github_secret_scanning_enabled", + "checktitle": "Check if secret scanning is enabled in GitHub", + "Description": "Checks if secret scanning is enabled.", + }, + first_seen_at="2024-01-02T00:00:00Z", + muted=False, + ) + finding_new.add_resources([resource2]) + findings.append(finding_new) + + from tasks.jobs.scan import aggregate_finding_group_summaries + + aggregate_finding_group_summaries( + tenant_id=str(tenant.id), + scan_id=str(scan1.id), + ) + aggregate_finding_group_summaries( + tenant_id=str(tenant.id), + scan_id=str(scan2.id), + ) + + return findings + + def pytest_collection_modifyitems(items): """Ensure test_rbac.py is executed first.""" items.sort(key=lambda item: 0 if "test_rbac.py" in item.nodeid else 1) diff --git a/api/src/backend/tasks/jobs/attack_paths/aws.py b/api/src/backend/tasks/jobs/attack_paths/aws.py index 9242946181..e7f26b5173 100644 --- a/api/src/backend/tasks/jobs/attack_paths/aws.py +++ b/api/src/backend/tasks/jobs/attack_paths/aws.py @@ -43,6 +43,7 @@ def start_aws_ingestion( "aws_guardduty_severity_threshold": cartography_config.aws_guardduty_severity_threshold, "aws_cloudtrail_management_events_lookback_hours": cartography_config.aws_cloudtrail_management_events_lookback_hours, "experimental_aws_inspector_batch": cartography_config.experimental_aws_inspector_batch, + "aws_tagging_api_cleanup_batch": cartography_config.aws_tagging_api_cleanup_batch, } boto3_session = get_boto3_session(prowler_api_provider, prowler_sdk_provider) @@ -116,6 +117,30 @@ def start_aws_ingestion( neo4j_session, common_job_parameters, ) + + if all( + s in requested_syncs + for s in ["ecs", "ec2:load_balancer_v2", "ec2:load_balancer_v2:expose"] + ): + logger.info( + f"Syncing lb_container_exposure scoped analysis for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.run_scoped_analysis_job( + "aws_lb_container_exposure.json", + neo4j_session, + common_job_parameters, + ) + + if all(s in requested_syncs for s in ["ec2:network_acls", "ec2:load_balancer_v2"]): + logger.info( + f"Syncing lb_nacl_direct scoped analysis for AWS account {prowler_api_provider.uid}" + ) + cartography_aws.run_scoped_analysis_job( + "aws_lb_nacl_direct.json", + neo4j_session, + common_job_parameters, + ) + db_utils.update_attack_paths_scan_progress(attack_paths_scan, 91) logger.info(f"Syncing metadata for AWS account {prowler_api_provider.uid}") @@ -239,8 +264,9 @@ def sync_aws_account( failed_syncs[func_name] = exception_message logger.warning( - f"Caught exception syncing function {func_name} from AWS account {prowler_api_provider.uid}. We " - "are continuing on to the next AWS sync function.", + f"Caught exception syncing function {func_name} from AWS account {prowler_api_provider.uid}: {e}. " + "Continuing to the next AWS sync function.", + exc_info=True, ) continue diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py new file mode 100644 index 0000000000..1d81306bcc --- /dev/null +++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py @@ -0,0 +1,152 @@ +from datetime import datetime, timedelta, timezone + +from celery import current_app, states +from celery.utils.log import get_task_logger +from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES +from tasks.jobs.attack_paths.db_utils import ( + _mark_scan_finished, + recover_graph_data_ready, +) + +from api.attack_paths import database as graph_database +from api.db_router import MainRouter +from api.db_utils import rls_transaction +from api.models import AttackPathsScan, StateChoices + +logger = get_task_logger(__name__) + + +def cleanup_stale_attack_paths_scans() -> dict: + """ + Find `EXECUTING` `AttackPathsScan` scans whose workers are dead or that have + exceeded the stale threshold, and mark them as `FAILED`. + + Two-pass detection: + 1. If `TaskResult.worker` exists, ping the worker. + - Dead worker: cleanup immediately (any age). + - Alive + past threshold: revoke the task, then cleanup. + - Alive + within threshold: skip. + 2. If no worker field: fall back to time-based heuristic only. + """ + threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES) + now = datetime.now(tz=timezone.utc) + cutoff = now - threshold + + executing_scans = ( + AttackPathsScan.all_objects.using(MainRouter.admin_db) + .filter(state=StateChoices.EXECUTING) + .select_related("task__task_runner_task") + ) + + # Cache worker liveness so each worker is pinged at most once + executing_scans = list(executing_scans) + workers = { + tr.worker + for scan in executing_scans + if (tr := getattr(scan.task, "task_runner_task", None) if scan.task else None) + and tr.worker + } + worker_alive = {w: _is_worker_alive(w) for w in workers} + + cleaned_up = [] + + for scan in executing_scans: + task_result = ( + getattr(scan.task, "task_runner_task", None) if scan.task else None + ) + worker = task_result.worker if task_result else None + + if worker: + alive = worker_alive.get(worker, True) + + if alive: + if scan.started_at and scan.started_at >= cutoff: + continue + + # Alive but stale — revoke before cleanup + _revoke_task(task_result) + reason = ( + "Scan exceeded stale threshold — " "cleaned up by periodic task" + ) + else: + reason = "Worker dead — cleaned up by periodic task" + else: + # No worker recorded — time-based heuristic only + if scan.started_at and scan.started_at >= cutoff: + continue + reason = ( + "No worker recorded, scan exceeded stale threshold — " + "cleaned up by periodic task" + ) + + if _cleanup_scan(scan, task_result, reason): + cleaned_up.append(str(scan.id)) + + logger.info( + f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up" + ) + return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up} + + +def _is_worker_alive(worker: str) -> bool: + """Ping a specific Celery worker. Returns `True` if it responds or on error.""" + try: + response = current_app.control.inspect(destination=[worker], timeout=1.0).ping() + return response is not None and worker in response + except Exception: + logger.exception(f"Failed to ping worker {worker}, treating as alive") + return True + + +def _revoke_task(task_result) -> None: + """Send `SIGTERM` to a hung Celery task. Non-fatal on failure.""" + try: + current_app.control.revoke( + task_result.task_id, terminate=True, signal="SIGTERM" + ) + logger.info(f"Revoked task {task_result.task_id}") + except Exception: + logger.exception(f"Failed to revoke task {task_result.task_id}") + + +def _cleanup_scan(scan, task_result, reason: str) -> bool: + """ + Clean up a single stale `AttackPathsScan`: + drop temp DB, mark `FAILED`, update `TaskResult`, recover `graph_data_ready`. + + Returns `True` if the scan was actually cleaned up, `False` if skipped. + """ + scan_id_str = str(scan.id) + + # 1. Drop temp Neo4j database + tmp_db_name = graph_database.get_database_name(scan.id, temporary=True) + try: + graph_database.drop_database(tmp_db_name) + except Exception: + logger.exception(f"Failed to drop temp database {tmp_db_name}") + + # 2. Lock row, verify still EXECUTING, mark FAILED — all atomic + with rls_transaction(str(scan.tenant_id)): + try: + fresh_scan = AttackPathsScan.objects.select_for_update().get(id=scan.id) + except AttackPathsScan.DoesNotExist: + logger.warning(f"Scan {scan_id_str} no longer exists, skipping") + return False + + if fresh_scan.state != StateChoices.EXECUTING: + logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping") + return False + + _mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason}) + + # 3. Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock) + if task_result: + task_result.status = states.FAILURE + task_result.date_done = datetime.now(tz=timezone.utc) + task_result.save(update_fields=["status", "date_done"]) + + # 4. Recover graph_data_ready if provider data still exists + recover_graph_data_ready(fresh_scan) + + logger.info(f"Cleaned up stale scan {scan_id_str}: {reason}") + return True diff --git a/api/src/backend/tasks/jobs/attack_paths/config.py b/api/src/backend/tasks/jobs/attack_paths/config.py index 1667d314a7..76dbdc1dc5 100644 --- a/api/src/backend/tasks/jobs/attack_paths/config.py +++ b/api/src/backend/tasks/jobs/attack_paths/config.py @@ -1,25 +1,30 @@ from dataclasses import dataclass from typing import Callable +from uuid import UUID from config.env import env - from tasks.jobs.attack_paths import aws - -# Batch size for Neo4j operations +# Batch size for Neo4j write operations (resource labeling, cleanup) BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000) +# Batch size for Postgres findings fetch (keyset pagination page size) +FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 500) +# Batch size for temp-to-tenant graph sync (nodes and relationships per cursor page) +SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 250) # Neo4j internal labels (Prowler-specific, not provider-specific) +# - `Internet`: Singleton node representing external internet access for exposed-resource queries # - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources # - `_ProviderResource`: Added to ALL synced nodes for provider isolation and drop/query ops -# - `Internet`: Singleton node representing external internet access for exposed-resource queries +INTERNET_NODE_LABEL = "Internet" PROWLER_FINDING_LABEL = "ProwlerFinding" PROVIDER_RESOURCE_LABEL = "_ProviderResource" -INTERNET_NODE_LABEL = "Internet" -# Phase 1 dual-write: deprecated label kept for drop_subgraph and infrastructure queries -# Remove in Phase 2 once all nodes use the private label exclusively -DEPRECATED_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} +TENANT_LABEL_PREFIX = "_Tenant_" +PROVIDER_LABEL_PREFIX = "_Provider_" +DYNAMIC_ISOLATION_PREFIXES = [TENANT_LABEL_PREFIX, PROVIDER_LABEL_PREFIX] @dataclass(frozen=True) @@ -31,7 +36,6 @@ class ProviderConfig: uid_field: str # e.g., "arn" # Label for resources connected to the account node, enabling indexed finding lookups. resource_label: str # e.g., "_AWSResource" - deprecated_resource_label: str # e.g., "AWSResource" ingestion_function: Callable @@ -43,7 +47,6 @@ AWS_CONFIG = ProviderConfig( root_node_label="AWSAccount", uid_field="arn", resource_label="_AWSResource", - deprecated_resource_label="AWSResource", ingestion_function=aws.start_aws_ingestion, ) @@ -56,18 +59,14 @@ PROVIDER_CONFIGS: dict[str, ProviderConfig] = { INTERNAL_LABELS: list[str] = [ "Tenant", # From Cartography, but it looks like it's ours PROVIDER_RESOURCE_LABEL, - DEPRECATED_PROVIDER_RESOURCE_LABEL, - # Add all provider-specific resource labels *[config.resource_label for config in PROVIDER_CONFIGS.values()], - *[config.deprecated_resource_label for config in PROVIDER_CONFIGS.values()], ] # Provider isolation properties +PROVIDER_ELEMENT_ID_PROPERTY = "_provider_element_id" + PROVIDER_ISOLATION_PROPERTIES: list[str] = [ - "_provider_id", - "_provider_element_id", - "provider_id", - "provider_element_id", + PROVIDER_ELEMENT_ID_PROPERTY, ] # Cartography bookkeeping metadata @@ -117,7 +116,25 @@ def get_provider_resource_label(provider_type: str) -> str: return config.resource_label if config else "_UnknownProviderResource" -def get_deprecated_provider_resource_label(provider_type: str) -> str: - """Get the deprecated resource label for a provider type (e.g., `AWSResource`).""" - config = PROVIDER_CONFIGS.get(provider_type) - return config.deprecated_resource_label if config else "UnknownProviderResource" +# Dynamic Isolation Label Helpers +# -------------------------------- + + +def _normalize_uuid(value: str | UUID) -> str: + """Strip hyphens from a UUID string for use in Neo4j labels.""" + return str(value).replace("-", "") + + +def get_tenant_label(tenant_id: str | UUID) -> str: + """Get the Neo4j label for a tenant (e.g., `_Tenant_019c41ee7df37deca684d839f95619f8`).""" + return f"{TENANT_LABEL_PREFIX}{_normalize_uuid(tenant_id)}" + + +def get_provider_label(provider_id: str | UUID) -> str: + """Get the Neo4j label for a provider (e.g., `_Provider_019c41ee7df37deca684d839f95619f8`).""" + return f"{PROVIDER_LABEL_PREFIX}{_normalize_uuid(provider_id)}" + + +def is_dynamic_isolation_label(label: str) -> bool: + """Check if a label is a dynamic tenant/provider isolation label.""" + return any(label.startswith(prefix) for prefix in DYNAMIC_ISOLATION_PREFIXES) 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 9fb52b0ead..9df99df619 100644 --- a/api/src/backend/tasks/jobs/attack_paths/db_utils.py +++ b/api/src/backend/tasks/jobs/attack_paths/db_utils.py @@ -3,15 +3,13 @@ from typing import Any from cartography.config import Config as CartographyConfig from celery.utils.log import get_task_logger +from tasks.jobs.attack_paths.config import is_provider_available from api.attack_paths import database as graph_database from api.db_utils import rls_transaction -from api.models import ( - AttackPathsScan as ProwlerAPIAttackPathsScan, - Provider as ProwlerAPIProvider, - StateChoices, -) -from tasks.jobs.attack_paths.config import is_provider_available +from api.models import AttackPathsScan as ProwlerAPIAttackPathsScan +from api.models import Provider as ProwlerAPIProvider +from api.models import StateChoices logger = get_task_logger(__name__) @@ -90,34 +88,41 @@ def starting_attack_paths_scan( ) +def _mark_scan_finished( + attack_paths_scan: ProwlerAPIAttackPathsScan, + state: StateChoices, + ingestion_exceptions: dict[str, Any], +) -> None: + """Set terminal fields on a scan. Caller must be inside a transaction.""" + now = datetime.now(tz=timezone.utc) + duration = ( + int((now - attack_paths_scan.started_at).total_seconds()) + if attack_paths_scan.started_at + else 0 + ) + attack_paths_scan.state = state + attack_paths_scan.progress = 100 + attack_paths_scan.completed_at = now + attack_paths_scan.duration = duration + attack_paths_scan.ingestion_exceptions = ingestion_exceptions + attack_paths_scan.save( + update_fields=[ + "state", + "progress", + "completed_at", + "duration", + "ingestion_exceptions", + ] + ) + + def finish_attack_paths_scan( attack_paths_scan: ProwlerAPIAttackPathsScan, state: StateChoices, ingestion_exceptions: dict[str, Any], ) -> None: with rls_transaction(attack_paths_scan.tenant_id): - now = datetime.now(tz=timezone.utc) - duration = ( - int((now - attack_paths_scan.started_at).total_seconds()) - if attack_paths_scan.started_at - else 0 - ) - - attack_paths_scan.state = state - attack_paths_scan.progress = 100 - attack_paths_scan.completed_at = now - attack_paths_scan.duration = duration - attack_paths_scan.ingestion_exceptions = ingestion_exceptions - - attack_paths_scan.save( - update_fields=[ - "state", - "progress", - "completed_at", - "duration", - "ingestion_exceptions", - ] - ) + _mark_scan_finished(attack_paths_scan, state, ingestion_exceptions) def update_attack_paths_scan_progress( @@ -155,6 +160,37 @@ def set_provider_graph_data_ready( attack_paths_scan.refresh_from_db(fields=["graph_data_ready"]) +def recover_graph_data_ready( + attack_paths_scan: ProwlerAPIAttackPathsScan, +) -> None: + """ + Best-effort recovery of `graph_data_ready` after a scan failure. + + Queries Neo4j to check if the provider still has data in the tenant + database. If data exists, restores `graph_data_ready=True` for all scans + of this provider. Never raises. + + Trade-off: if the worker crashed mid-sync, partial data may exist and + this will re-enable queries against it. We accept that because leaving + `graph_data_ready=False` permanently (blocking all queries until the + next successful scan) is a worse outcome for the user. + """ + try: + 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) + ): + set_provider_graph_data_ready(attack_paths_scan, True) + logger.info( + f"Recovered `graph_data_ready` for provider {attack_paths_scan.provider_id}" + ) + + except Exception: + logger.exception( + f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}" + ) + + def fail_attack_paths_scan( tenant_id: str, scan_id: str, @@ -165,23 +201,26 @@ def fail_attack_paths_scan( Used as a safety net when the Celery task fails outside the job's own error handling. """ attack_paths_scan = retrieve_attack_paths_scan(tenant_id, scan_id) - if attack_paths_scan and attack_paths_scan.state not in ( - StateChoices.COMPLETED, - StateChoices.FAILED, - ): - tmp_db_name = graph_database.get_database_name( - attack_paths_scan.id, temporary=True + if not attack_paths_scan: + return + + tmp_db_name = graph_database.get_database_name(attack_paths_scan.id, temporary=True) + try: + graph_database.drop_database(tmp_db_name) + except Exception: + logger.exception( + f"Failed to drop temp database {tmp_db_name} during failure handling" ) + + with rls_transaction(tenant_id): try: - graph_database.drop_database(tmp_db_name) - - except Exception: - logger.exception( - f"Failed to drop temp database {tmp_db_name} during failure handling" + fresh = ProwlerAPIAttackPathsScan.objects.select_for_update().get( + id=attack_paths_scan.id ) + except ProwlerAPIAttackPathsScan.DoesNotExist: + return + if fresh.state in (StateChoices.COMPLETED, StateChoices.FAILED): + return + _mark_scan_finished(fresh, StateChoices.FAILED, {"global_error": error}) - finish_attack_paths_scan( - attack_paths_scan, - 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 468f805cdd..4a01b925d7 100644 --- a/api/src/backend/tasks/jobs/attack_paths/findings.py +++ b/api/src/backend/tasks/jobs/attack_paths/findings.py @@ -9,28 +9,19 @@ This module handles: """ from collections import defaultdict -from dataclasses import asdict, dataclass, fields from typing import Any, Generator from uuid import UUID import neo4j - from cartography.config import Config as CartographyConfig from celery.utils.log import get_task_logger - -from api.db_router import READ_REPLICA_ALIAS -from api.db_utils import rls_transaction -from api.models import Finding as FindingModel -from api.models import Provider, ResourceFindingMapping -from prowler.config import config as ProwlerConfig from tasks.jobs.attack_paths.config import ( BATCH_SIZE, - get_deprecated_provider_resource_label, + FINDINGS_BATCH_SIZE, get_node_uid_field, get_provider_resource_label, get_root_node_label, ) -from tasks.jobs.attack_paths.indexes import IndexType, create_indexes from tasks.jobs.attack_paths.queries import ( ADD_RESOURCE_LABEL_TEMPLATE, CLEANUP_FINDINGS_TEMPLATE, @@ -38,86 +29,60 @@ from tasks.jobs.attack_paths.queries import ( render_cypher_template, ) +from api.db_router import READ_REPLICA_ALIAS +from api.db_utils import rls_transaction +from api.models import Finding as FindingModel +from api.models import Provider, ResourceFindingMapping +from prowler.config import config as ProwlerConfig + logger = get_task_logger(__name__) -# Type Definitions -# ----------------- - -# Maps dataclass field names to Django ORM query field names -_DB_FIELD_MAP: dict[str, str] = { - "check_title": "check_metadata__checktitle", -} +# Django ORM field names for `.values()` queries +# Most map 1:1 to Neo4j property names, exceptions are remapped in `_to_neo4j_dict` +_DB_QUERY_FIELDS = [ + "id", + "uid", + "inserted_at", + "updated_at", + "first_seen_at", + "scan_id", + "delta", + "status", + "status_extended", + "severity", + "check_id", + "check_metadata__checktitle", + "muted", + "muted_reason", +] -@dataclass(slots=True) -class Finding: - """ - Finding data for Neo4j ingestion. - - Can be created from a Django .values() query result using from_db_record(). - """ - - id: str - uid: str - inserted_at: str - updated_at: str - first_seen_at: str - scan_id: str - delta: str - status: str - status_extended: str - severity: str - check_id: str - check_title: str - muted: bool - muted_reason: str | None - resource_uid: str | None = None - - @classmethod - def get_db_query_fields(cls) -> tuple[str, ...]: - """Get field names for Django .values() query.""" - return tuple( - _DB_FIELD_MAP.get(f.name, f.name) - for f in fields(cls) - if f.name != "resource_uid" - ) - - @classmethod - def from_db_record(cls, record: dict[str, Any], resource_uid: str) -> "Finding": - """Create a Finding from a Django .values() query result.""" - return cls( - id=str(record["id"]), - uid=record["uid"], - inserted_at=record["inserted_at"], - updated_at=record["updated_at"], - first_seen_at=record["first_seen_at"], - scan_id=str(record["scan_id"]), - delta=record["delta"], - status=record["status"], - status_extended=record["status_extended"], - severity=record["severity"], - check_id=str(record["check_id"]), - check_title=record["check_metadata__checktitle"], - muted=record["muted"], - muted_reason=record["muted_reason"], - resource_uid=resource_uid, - ) - - def to_dict(self) -> dict[str, Any]: - """Convert to dict for Neo4j ingestion.""" - return asdict(self) +def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]: + """Transform a Django `.values()` record into a `dict` ready for Neo4j ingestion.""" + return { + "id": str(record["id"]), + "uid": record["uid"], + "inserted_at": record["inserted_at"], + "updated_at": record["updated_at"], + "first_seen_at": record["first_seen_at"], + "scan_id": str(record["scan_id"]), + "delta": record["delta"], + "status": record["status"], + "status_extended": record["status_extended"], + "severity": record["severity"], + "check_id": str(record["check_id"]), + "check_title": record["check_metadata__checktitle"], + "muted": record["muted"], + "muted_reason": record["muted_reason"], + "resource_uid": resource_uid, + } # Public API # ---------- -def create_findings_indexes(neo4j_session: neo4j.Session) -> None: - """Create indexes for Prowler findings and resource lookups.""" - create_indexes(neo4j_session, IndexType.FINDINGS) - - def analysis( neo4j_session: neo4j.Session, prowler_api_provider: Provider, @@ -153,9 +118,6 @@ def add_resource_label( { "__ROOT_LABEL__": get_root_node_label(provider_type), "__RESOURCE_LABEL__": get_provider_resource_label(provider_type), - "__DEPRECATED_RESOURCE_LABEL__": get_deprecated_provider_resource_label( - provider_type - ), }, ) @@ -184,7 +146,7 @@ def add_resource_label( def load_findings( neo4j_session: neo4j.Session, - findings_batches: Generator[list[Finding], None, None], + findings_batches: Generator[list[dict[str, Any]], None, None], prowler_api_provider: Provider, config: CartographyConfig, ) -> None: @@ -213,7 +175,7 @@ def load_findings( batch_size = len(batch) total_records += batch_size - parameters["findings_data"] = [f.to_dict() for f in batch] + parameters["findings_data"] = batch logger.info(f"Loading findings batch {batch_num} ({batch_size} records)") neo4j_session.run(query, parameters) @@ -228,7 +190,6 @@ def cleanup_findings( ) -> None: """Remove stale findings (classic Cartography behaviour).""" parameters = { - "provider_uid": str(prowler_api_provider.uid), "last_updated": config.update_tag, "batch_size": BATCH_SIZE, } @@ -251,16 +212,17 @@ def cleanup_findings( def stream_findings_with_resources( prowler_api_provider: Provider, scan_id: str, -) -> Generator[list[Finding], None, None]: +) -> Generator[list[dict[str, Any]], None, None]: """ Stream findings with their associated resources in batches. Uses keyset pagination for efficient traversal of large datasets. - Memory efficient: yields one batch at a time, never holds all findings in memory. + Memory efficient: yields one batch at a time as dicts ready for Neo4j ingestion, + never holds all findings in memory. """ logger.info( f"Starting findings stream for scan {scan_id} " - f"(tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}" + f"(tenant {prowler_api_provider.tenant_id}) with batch size {FINDINGS_BATCH_SIZE}" ) tenant_id = prowler_api_provider.tenant_id @@ -309,15 +271,14 @@ def _fetch_findings_batch( Uses read replica and RLS-scoped transaction. """ with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - # Use all_objects to avoid the ActiveProviderManager's implicit JOIN - # through Scan -> Provider (to check is_deleted=False). - # The provider is already validated as active in this context. + # Use `all_objects` to get `Findings` even on soft-deleted `Providers` + # But even the provider is already validated as active in this context qs = FindingModel.all_objects.filter(scan_id=scan_id).order_by("id") if after_id is not None: qs = qs.filter(id__gt=after_id) - return list(qs.values(*Finding.get_db_query_fields())[:BATCH_SIZE]) + return list(qs.values(*_DB_QUERY_FIELDS)[:FINDINGS_BATCH_SIZE]) # Batch Enrichment @@ -327,7 +288,7 @@ def _fetch_findings_batch( def _enrich_batch_with_resources( findings_batch: list[dict[str, Any]], tenant_id: str, -) -> list[Finding]: +) -> list[dict[str, Any]]: """ Enrich findings with their resource UIDs. @@ -338,7 +299,7 @@ def _enrich_batch_with_resources( resource_map = _build_finding_resource_map(finding_ids, tenant_id) return [ - Finding.from_db_record(finding, resource_uid) + _to_neo4j_dict(finding, resource_uid) for finding in findings_batch for resource_uid in resource_map.get(finding["id"], []) ] diff --git a/api/src/backend/tasks/jobs/attack_paths/indexes.py b/api/src/backend/tasks/jobs/attack_paths/indexes.py index 69edfe6719..94855e4082 100644 --- a/api/src/backend/tasks/jobs/attack_paths/indexes.py +++ b/api/src/backend/tasks/jobs/attack_paths/indexes.py @@ -1,37 +1,25 @@ -from enum import Enum - import neo4j from cartography.client.core.tx import run_write_query from celery.utils.log import get_task_logger from tasks.jobs.attack_paths.config import ( - DEPRECATED_PROVIDER_RESOURCE_LABEL, INTERNET_NODE_LABEL, PROWLER_FINDING_LABEL, + PROVIDER_ELEMENT_ID_PROPERTY, PROVIDER_RESOURCE_LABEL, ) logger = get_task_logger(__name__) -class IndexType(Enum): - """Types of indexes that can be created.""" - - FINDINGS = "findings" - SYNC = "sync" - - # Indexes for Prowler findings and resource lookups FINDINGS_INDEX_STATEMENTS = [ # Resource indexes for Prowler Finding lookups "CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:_AWSResource) ON (n.arn);", "CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:_AWSResource) ON (n.id);", - "CREATE INDEX deprecated_aws_resource_arn IF NOT EXISTS FOR (n:AWSResource) ON (n.arn);", - "CREATE INDEX deprecated_aws_resource_id IF NOT EXISTS FOR (n:AWSResource) ON (n.id);", # Prowler Finding indexes f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);", - f"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.provider_uid);", f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);", f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);", # Internet node index for MERGE lookups @@ -40,33 +28,19 @@ FINDINGS_INDEX_STATEMENTS = [ # Indexes for provider resource sync operations SYNC_INDEX_STATEMENTS = [ - f"CREATE INDEX provider_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n._provider_element_id);", - f"CREATE INDEX provider_resource_provider_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n._provider_id);", - f"CREATE INDEX deprecated_provider_element_id IF NOT EXISTS FOR (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL}) ON (n.provider_element_id);", - f"CREATE INDEX deprecated_provider_resource_provider_id IF NOT EXISTS FOR (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL}) ON (n.provider_id);", + f"CREATE INDEX provider_resource_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.{PROVIDER_ELEMENT_ID_PROPERTY});", ] -def create_indexes(neo4j_session: neo4j.Session, index_type: IndexType) -> None: - """ - Create indexes for the specified type. - - Args: - `neo4j_session`: The Neo4j session to use - `index_type`: The type of indexes to create (FINDINGS or SYNC) - """ - if index_type == IndexType.FINDINGS: - logger.info("Creating indexes for Prowler Findings node types") - for statement in FINDINGS_INDEX_STATEMENTS: - run_write_query(neo4j_session, statement) - - elif index_type == IndexType.SYNC: - logger.info("Ensuring ProviderResource indexes exist") - for statement in SYNC_INDEX_STATEMENTS: - neo4j_session.run(statement) +def create_findings_indexes(neo4j_session: neo4j.Session) -> None: + """Create indexes for Prowler findings and resource lookups.""" + logger.info("Creating indexes for Prowler Findings node types") + for statement in FINDINGS_INDEX_STATEMENTS: + run_write_query(neo4j_session, statement) -def create_all_indexes(neo4j_session: neo4j.Session) -> None: - """Create all indexes (both findings and sync).""" - create_indexes(neo4j_session, IndexType.FINDINGS) - create_indexes(neo4j_session, IndexType.SYNC) +def create_sync_indexes(neo4j_session: neo4j.Session) -> None: + """Create indexes for provider resource sync operations.""" + 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/queries.py b/api/src/backend/tasks/jobs/attack_paths/queries.py index 4eada6684f..64bbc428f8 100644 --- a/api/src/backend/tasks/jobs/attack_paths/queries.py +++ b/api/src/backend/tasks/jobs/attack_paths/queries.py @@ -2,6 +2,7 @@ from tasks.jobs.attack_paths.config import ( INTERNET_NODE_LABEL, PROWLER_FINDING_LABEL, + PROVIDER_ELEMENT_ID_PROPERTY, PROVIDER_RESOURCE_LABEL, ) @@ -26,7 +27,7 @@ ADD_RESOURCE_LABEL_TEMPLATE = """ MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r) WHERE NOT r:__ROOT_LABEL__ AND NOT r:__RESOURCE_LABEL__ WITH r LIMIT $batch_size - SET r:__RESOURCE_LABEL__:__DEPRECATED_RESOURCE_LABEL__ + SET r:__RESOURCE_LABEL__ RETURN COUNT(r) AS labeled_count """ @@ -60,7 +61,6 @@ INSERT_FINDING_TEMPLATE = f""" finding.check_title = finding_data.check_title, finding.muted = finding_data.muted, finding.muted_reason = finding_data.muted_reason, - finding.provider_uid = $provider_uid, finding.firstseen = timestamp(), finding.lastupdated = $last_updated, finding._module_name = 'cartography:prowler', @@ -72,7 +72,6 @@ INSERT_FINDING_TEMPLATE = f""" MERGE (resource)-[rel:HAS_FINDING]->(finding) ON CREATE SET - rel.provider_uid = $provider_uid, rel.firstseen = timestamp(), rel.lastupdated = $last_updated, rel._module_name = 'cartography:prowler', @@ -82,7 +81,7 @@ INSERT_FINDING_TEMPLATE = f""" """ CLEANUP_FINDINGS_TEMPLATE = f""" - MATCH (finding:{PROWLER_FINDING_LABEL} {{provider_uid: $provider_uid}}) + MATCH (finding:{PROWLER_FINDING_LABEL}) WHERE finding.lastupdated < $last_updated WITH finding LIMIT $batch_size @@ -149,22 +148,16 @@ RELATIONSHIPS_FETCH_QUERY = """ LIMIT $batch_size """ -NODE_SYNC_TEMPLATE = """ +NODE_SYNC_TEMPLATE = f""" UNWIND $rows AS row - MERGE (n:__NODE_LABELS__ {_provider_element_id: row.provider_element_id}) + MERGE (n:__NODE_LABELS__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}) SET n += row.props - SET n._provider_id = $provider_id - SET n.provider_element_id = row.provider_element_id - SET n.provider_id = $provider_id -""" # The last two lines are deprecated properties +""" RELATIONSHIP_SYNC_TEMPLATE = f""" UNWIND $rows AS row - MATCH (s:{PROVIDER_RESOURCE_LABEL} {{_provider_element_id: row.start_element_id}}) - MATCH (t:{PROVIDER_RESOURCE_LABEL} {{_provider_element_id: row.end_element_id}}) - MERGE (s)-[r:__REL_TYPE__ {{_provider_element_id: row.provider_element_id}}]->(t) + 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 - SET r._provider_id = $provider_id - SET r.provider_element_id = row.provider_element_id - SET r.provider_id = $provider_id -""" # The last two lines are deprecated properties +""" diff --git a/api/src/backend/tasks/jobs/attack_paths/scan.py b/api/src/backend/tasks/jobs/attack_paths/scan.py index cd39700dd4..a53a6a530f 100644 --- a/api/src/backend/tasks/jobs/attack_paths/scan.py +++ b/api/src/backend/tasks/jobs/attack_paths/scan.py @@ -1,6 +1,60 @@ +""" +Attack Paths scan orchestrator. + +Runs the full scan lifecycle for a single provider, called from a Celery task. +The idea is simple: ingest everything into a throwaway Neo4j database, enrich +it with Prowler-specific data, then swap it into the tenant's long-lived +database so queries never see a half-built graph. + +Two databases are involved: +- Temporary (db-tmp-scan-): short-lived, single-provider, dropped after sync. +- Tenant (db-tenant-): long-lived, multi-provider, what the API queries against. + +Pipeline steps: + +1. Resolve the Prowler provider and SDK credentials from the scan ID. + Retrieve or create the AttackPathsScan row. Exit early if the provider + type has no ingestion function (only AWS is supported today). + +2. Create a fresh temporary Neo4j database and set up Cartography indexes + plus ProwlerFinding indexes before writing any data. + +3. Run the provider-specific Cartography ingestion (e.g. aws.start_aws_ingestion). + This iterates over cloud services and writes the standard Cartography nodes + (AWSAccount, EC2Instance, IAMRole, etc.) and relationships (RESOURCE, + POLICY, STATEMENT, TRUSTS_AWS_PRINCIPAL, ...) into the temp database. + Wrapped in call_within_event_loop because some Cartography modules use async. + +4. Run Cartography post-processing: ontology for label propagation and + analysis for derived relationships. + +5. Create an Internet singleton node and add CAN_ACCESS relationships to + internet-exposed resources (EC2Instance, LoadBalancer, LoadBalancerV2). + +6. Stream Prowler findings from Postgres in batches. Each finding becomes a + ProwlerFinding node linked to its cloud-resource node via HAS_FINDING. + Before that, an _AWSResource label (provider-specific) is added to all + nodes connected to the AWSAccount so finding lookups can use an index. + Stale findings from previous scans are cleaned up. + +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. + - 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. + - Set graph_data_ready back to True. + +8. Drop the temporary database, mark the AttackPathsScan as COMPLETED. + +On failure the temp database is dropped, the scan is marked FAILED, and the +exception propagates to Celery. + +""" + import logging import time - from typing import Any from cartography.config import Config as CartographyConfig @@ -8,16 +62,14 @@ 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 tasks.jobs.attack_paths.config import get_cartography_ingestion_function from api.attack_paths import database as graph_database from api.db_utils import rls_transaction -from api.models import ( - Provider as ProwlerAPIProvider, - StateChoices, -) +from api.models import Provider as ProwlerAPIProvider +from api.models import StateChoices from api.utils import initialize_prowler_provider -from tasks.jobs.attack_paths import db_utils, findings, internet, sync, utils -from tasks.jobs.attack_paths.config import get_cartography_ingestion_function # Without this Celery goes crazy with Cartography logging logging.getLogger("cartography").setLevel(logging.ERROR) @@ -92,6 +144,10 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: attack_paths_scan, task_id, tenant_cartography_config ) + subgraph_dropped = False + sync_completed = False + provider_gated = False + try: logger.info( f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}" @@ -109,7 +165,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: ) as tmp_neo4j_session: # Indexes creation cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config) - findings.create_findings_indexes(tmp_neo4j_session) + indexes.create_findings_indexes(tmp_neo4j_session) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2) # The real scan, where iterates over cloud services @@ -165,15 +221,17 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: cartography_create_indexes.run( tenant_neo4j_session, tenant_cartography_config ) - findings.create_findings_indexes(tenant_neo4j_session) - sync.create_sync_indexes(tenant_neo4j_session) + indexes.create_findings_indexes(tenant_neo4j_session) + indexes.create_sync_indexes(tenant_neo4j_session) logger.info(f"Deleting existing provider graph in {tenant_database_name}") db_utils.set_provider_graph_data_ready(attack_paths_scan, False) + provider_gated = True graph_database.drop_subgraph( database=tenant_database_name, provider_id=str(prowler_api_provider.id), ) + subgraph_dropped = True db_utils.update_attack_paths_scan_progress(attack_paths_scan, 98) logger.info( @@ -182,8 +240,10 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: sync.sync_graph( source_database=tmp_database_name, target_database=tenant_database_name, + tenant_id=str(prowler_api_provider.tenant_id), provider_id=str(prowler_api_provider.id), ) + sync_completed = True db_utils.set_graph_data_ready(attack_paths_scan, True) db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99) @@ -208,22 +268,40 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]: logger.exception(exception_message) ingestion_exceptions["global_error"] = exception_message - # Handling databases changes + # 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: - graph_database.drop_database(tmp_cartography_config.neo4j_database) + 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) except Exception: logger.error( - f"Failed to drop temporary Neo4j database {tmp_cartography_config.neo4j_database} during cleanup" + f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}", + exc_info=True, ) + # Dropping the temporary database if it still exists + try: + graph_database.drop_database(tmp_cartography_config.neo4j_database) + + except Exception as e: + logger.error( + f"Failed to drop temporary Neo4j database `{tmp_cartography_config.neo4j_database}` during cleanup: {e}", + exc_info=True, + ) + + # Set Attack Paths scan state to FAILED try: db_utils.finish_attack_paths_scan( attack_paths_scan, StateChoices.FAILED, ingestion_exceptions ) - except Exception: - logger.warning( - f"Could not mark attack paths scan {attack_paths_scan.id} as FAILED (row may have been deleted)" + except Exception as e: + logger.error( + f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` (row may have been deleted): {e}", + exc_info=True, ) raise diff --git a/api/src/backend/tasks/jobs/attack_paths/sync.py b/api/src/backend/tasks/jobs/attack_paths/sync.py index 2fc94ab540..24ffa6cf48 100644 --- a/api/src/backend/tasks/jobs/attack_paths/sync.py +++ b/api/src/backend/tasks/jobs/attack_paths/sync.py @@ -8,16 +8,15 @@ to the tenant database, adding provider isolation labels and properties. from collections import defaultdict from typing import Any +import neo4j from celery.utils.log import get_task_logger - -from api.attack_paths import database as graph_database from tasks.jobs.attack_paths.config import ( - BATCH_SIZE, - DEPRECATED_PROVIDER_RESOURCE_LABEL, PROVIDER_ISOLATION_PROPERTIES, PROVIDER_RESOURCE_LABEL, + SYNC_BATCH_SIZE, + get_provider_label, + get_tenant_label, ) -from tasks.jobs.attack_paths.indexes import IndexType, create_indexes from tasks.jobs.attack_paths.queries import ( NODE_FETCH_QUERY, NODE_SYNC_TEMPLATE, @@ -26,17 +25,15 @@ from tasks.jobs.attack_paths.queries import ( render_cypher_template, ) +from api.attack_paths import database as graph_database + logger = get_task_logger(__name__) -def create_sync_indexes(neo4j_session) -> None: - """Create indexes for provider resource sync operations.""" - create_indexes(neo4j_session, IndexType.SYNC) - - def sync_graph( source_database: str, target_database: str, + tenant_id: str, provider_id: str, ) -> dict[str, int]: """ @@ -45,6 +42,7 @@ def sync_graph( Args: `source_database`: The temporary scan database `target_database`: The tenant database + `tenant_id`: The tenant ID for isolation `provider_id`: The provider ID for isolation Returns: @@ -53,6 +51,7 @@ def sync_graph( nodes_synced = sync_nodes( source_database, target_database, + tenant_id, provider_id, ) relationships_synced = sync_relationships( @@ -70,67 +69,56 @@ def sync_graph( def sync_nodes( source_database: str, target_database: str, + tenant_id: str, provider_id: str, ) -> int: """ Sync nodes from source to target database. - Adds `_ProviderResource` label and `_provider_id` property to all nodes. + Adds `_ProviderResource` label and dynamic `_Tenant_{id}` and `_Provider_{id}` + isolation labels to all nodes. + + Source and target sessions are opened sequentially per batch to avoid + holding two Bolt connections simultaneously for the entire sync duration. """ last_id = -1 total_synced = 0 - with ( - graph_database.get_session(source_database) as source_session, - graph_database.get_session(target_database) as target_session, - ): - while True: - rows = list( - source_session.run( - NODE_FETCH_QUERY, - {"last_id": last_id, "batch_size": BATCH_SIZE}, - ) + while True: + grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) + batch_count = 0 + + with graph_database.get_session(source_database) as source_session: + result = source_session.run( + NODE_FETCH_QUERY, + {"last_id": last_id, "batch_size": SYNC_BATCH_SIZE}, ) + 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) - if not rows: - break - - last_id = rows[-1]["internal_id"] - - grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list) - for row in rows: - labels = tuple(sorted(set(row["labels"] or []))) - props = dict(row["props"] or {}) - _strip_internal_properties(props) - provider_element_id = f"{provider_id}:{row['element_id']}" - grouped[labels].append( - { - "provider_element_id": provider_element_id, - "props": props, - } - ) + 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(DEPRECATED_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)) query = render_cypher_template( NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels} ) - target_session.run( - query, - { - "rows": batch, - "provider_id": provider_id, - }, - ) + target_session.run(query, {"rows": batch}) - total_synced += len(rows) - logger.info( - f"Synced {total_synced} nodes from {source_database} to {target_database}" - ) + total_synced += batch_count + logger.info( + f"Synced {total_synced} nodes from {source_database} to {target_database}" + ) return total_synced @@ -143,62 +131,75 @@ def sync_relationships( """ Sync relationships from source to target database. - Adds `_provider_id` property to all relationships. + 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. """ last_id = -1 total_synced = 0 - with ( - graph_database.get_session(source_database) as source_session, - graph_database.get_session(target_database) as target_session, - ): - while True: - rows = list( - source_session.run( - RELATIONSHIPS_FETCH_QUERY, - {"last_id": last_id, "batch_size": BATCH_SIZE}, - ) + while True: + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + batch_count = 0 + + with graph_database.get_session(source_database) as source_session: + result = source_session.run( + RELATIONSHIPS_FETCH_QUERY, + {"last_id": last_id, "batch_size": SYNC_BATCH_SIZE}, ) + for record in result: + batch_count += 1 + last_id = record["internal_id"] + key, value = _rel_to_sync_dict(record, provider_id) + grouped[key].append(value) - if not rows: - break - - last_id = rows[-1]["internal_id"] - - grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) - for row in rows: - props = dict(row["props"] or {}) - _strip_internal_properties(props) - rel_type = row["rel_type"] - grouped[rel_type].append( - { - "start_element_id": f"{provider_id}:{row['start_element_id']}", - "end_element_id": f"{provider_id}:{row['end_element_id']}", - "provider_element_id": f"{provider_id}:{rel_type}:{row['internal_id']}", - "props": props, - } - ) + 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} ) - target_session.run( - query, - { - "rows": batch, - "provider_id": provider_id, - }, - ) + target_session.run(query, {"rows": batch}) - total_synced += len(rows) - logger.info( - f"Synced {total_synced} relationships from {source_database} to {target_database}" - ) + total_synced += batch_count + logger.info( + f"Synced {total_synced} relationships from {source_database} to {target_database}" + ) return total_synced +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.""" + 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']}", + "props": props, + } + + +def _rel_to_sync_dict( + record: neo4j.Record, provider_id: str +) -> tuple[str, dict[str, Any]]: + """Transform a source relationship record into a (grouping_key, sync_dict) pair.""" + props = dict(record["props"] or {}) + _strip_internal_properties(props) + 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']}", + "provider_element_id": f"{provider_id}:{rel_type}:{record['internal_id']}", + "props": props, + } + + 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: diff --git a/api/src/backend/tasks/jobs/reports/ens.py b/api/src/backend/tasks/jobs/reports/ens.py index 56ee4bc40f..617d4ea59f 100644 --- a/api/src/backend/tasks/jobs/reports/ens.py +++ b/api/src/backend/tasks/jobs/reports/ens.py @@ -336,7 +336,6 @@ class ENSReportGenerator(BaseComplianceReportGenerator): for req in data.requirements: if req.status == StatusChoices.MANUAL: continue - m = get_requirement_metadata(req.id, data.attributes_by_requirement_id) if m: marco = getattr(m, "Marco", "Otros") @@ -365,9 +364,12 @@ class ENSReportGenerator(BaseComplianceReportGenerator): elements.append(Paragraph(f"{categoria_name}", self.styles["h3"])) for req in reqs: - status_indicator = ( - "✓" if req["status"] == StatusChoices.PASS else "✗" - ) + if req["status"] == StatusChoices.PASS: + status_indicator = "✓" + elif req["status"] == StatusChoices.MANUAL: + status_indicator = "⊙" + else: + status_indicator = "✗" nivel_badge = f"[{req['nivel'].upper()}]" if req["nivel"] else "" elements.append( Paragraph( @@ -841,11 +843,14 @@ class ENSReportGenerator(BaseComplianceReportGenerator): elements.append(Spacer(1, 0.15 * inch)) # Status and Nivel badges row - status_color = COLOR_HIGH_RISK # FAIL + status_text = str(req.status).upper() + status_color = ( + COLOR_HIGH_RISK if req.status == StatusChoices.FAIL else COLOR_GRAY + ) nivel_color = nivel_colors.get(nivel, COLOR_GRAY) badges_row1 = [ - ["State:", "FAIL", "", f"Nivel: {nivel.upper()}"], + ["State:", status_text, "", f"Nivel: {nivel.upper()}"], ] badges_table1 = Table( badges_row1, diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index b70ce36a7f..3a96e892e1 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -677,6 +677,7 @@ def _process_finding_micro_batch( # Create finding object (don't save yet) check_metadata = finding.get_metadata() + check_metadata["compliance"] = finding.compliance finding_instance = Finding( tenant_id=tenant_id, uid=finding_uid, @@ -1887,7 +1888,8 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str): inserted_at=summary_timestamp, updated_at=updated_at, check_title=metadata.get("checktitle", ""), - check_description=metadata.get("Description", ""), + check_description=metadata.get("description", "") + or metadata.get("Description", ""), severity_order=row["severity_order"] or 1, pass_count=row["pass_count"], fail_count=row["fail_count"], diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py index 7d0f2b6ec3..2ef29484ee 100644 --- a/api/src/backend/tasks/jobs/threatscore_utils.py +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -4,7 +4,7 @@ from django.db.models import Count, Q from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction -from api.models import Finding, StatusChoices +from api.models import Finding, Scan, StatusChoices from prowler.lib.outputs.finding import Finding as FindingOutput logger = get_task_logger(__name__) @@ -35,11 +35,17 @@ def _aggregate_requirement_statistics_from_database( } """ requirement_statistics_by_check_id = {} - + # TODO: review when finding-resource relation changes from 1:1 with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): + # Pre-check: skip if the scan's provider is deleted (avoids JOINs in the main query) + if Scan.all_objects.filter(id=scan_id, provider__is_deleted=True).exists(): + return requirement_statistics_by_check_id + aggregated_statistics_queryset = ( Finding.all_objects.filter( - tenant_id=tenant_id, scan_id=scan_id, muted=False + tenant_id=tenant_id, + scan_id=scan_id, + muted=False, ) .values("check_id") .annotate( @@ -47,7 +53,10 @@ def _aggregate_requirement_statistics_from_database( "id", filter=Q(status__in=[StatusChoices.PASS, StatusChoices.FAIL]), ), - passed_findings=Count("id", filter=Q(status=StatusChoices.PASS)), + passed_findings=Count( + "id", + filter=Q(status=StatusChoices.PASS), + ), ) ) diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 2e31ebc0f0..fbd0440af8 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -11,8 +11,9 @@ from django_celery_beat.models import PeriodicTask from tasks.jobs.attack_paths import ( attack_paths_scan, can_provider_run_attack_paths_scan, - db_utils as attack_paths_db_utils, ) +from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils +from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans from tasks.jobs.backfill import ( backfill_compliance_summaries, backfill_daily_severity_summaries, @@ -406,6 +407,11 @@ def perform_attack_paths_scan_task(self, tenant_id: str, scan_id: str): ) +@shared_task(name="attack-paths-cleanup-stale-scans", queue="attack-paths-scans") +def cleanup_stale_attack_paths_scans_task(): + return cleanup_stale_attack_paths_scans() + + @shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,)) def delete_tenant_task(tenant_id: str): return delete_tenant(pk=tenant_id) @@ -760,6 +766,33 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str): return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id) +@shared_task( + base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview" +) +@set_tenant(keep_tenant=True) +def reaggregate_all_finding_group_summaries_task(tenant_id: str): + """Reaggregate finding group summaries for all providers' latest completed scans.""" + latest_scan_ids = list( + Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED) + .order_by("provider_id", "-completed_at", "-inserted_at") + .distinct("provider_id") + .values_list("id", flat=True) + ) + if latest_scan_ids: + logger.info( + "Reaggregating finding group summaries for %d scans: %s", + len(latest_scan_ids), + latest_scan_ids, + ) + group( + aggregate_finding_group_summaries_task.si( + tenant_id=tenant_id, scan_id=str(scan_id) + ) + for scan_id in latest_scan_ids + ).apply_async() + return {"scans_reaggregated": len(latest_scan_ids)} + + @shared_task(base=RLSTask, name="lighthouse-connection-check") @set_tenant def check_lighthouse_connection_task(lighthouse_config_id: str, tenant_id: str = None): 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 8132b7dad3..0eae2224f7 100644 --- a/api/src/backend/tasks/tests/test_attack_paths_scan.py +++ b/api/src/backend/tasks/tests/test_attack_paths_scan.py @@ -1,14 +1,14 @@ from contextlib import nullcontext +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import MagicMock, call, patch import pytest +from django_celery_results.models import TaskResult from tasks.jobs.attack_paths import findings as findings_module +from tasks.jobs.attack_paths import indexes as indexes_module 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.config import ( - get_deprecated_provider_resource_label, -) from tasks.jobs.attack_paths.scan import run as attack_paths_run from api.models import ( @@ -20,6 +20,7 @@ from api.models import ( Scan, StateChoices, StatusChoices, + Task, ) from prowler.lib.check.models import Severity @@ -39,10 +40,10 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") - @patch("tasks.jobs.attack_paths.scan.sync.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") @patch("tasks.jobs.attack_paths.scan.internet.analysis") @patch("tasks.jobs.attack_paths.scan.findings.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @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") @@ -154,6 +155,7 @@ class TestAttackPathsRun: mock_sync.assert_called_once_with( source_database="db-scan-id", target_database="tenant-db", + tenant_id=str(provider.tenant_id), provider_id=str(provider.id), ) mock_get_ingestion.assert_called_once_with(provider.provider) @@ -188,7 +190,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.findings.analysis") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @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.scan.graph_database.create_database") @@ -271,6 +273,106 @@ class TestAttackPathsRun: assert failure_args[1] == StateChoices.FAILED assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"} + @patch( + "tasks.jobs.attack_paths.scan.utils.stringify_exception", + return_value="Cartography failed: ingestion boom", + ) + @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.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_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") + @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") + @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @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.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.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ) + @patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_failure_before_gate_does_not_flip_graph_data_ready_true( + self, + mock_init_provider, + mock_get_uri, + mock_get_db_name, + mock_create_db, + mock_cartography_indexes, + mock_cartography_analysis, + mock_findings_indexes, + mock_internet_analysis, + mock_findings_analysis, + mock_starting, + mock_update_progress, + mock_set_provider_graph_data_ready, + mock_set_graph_data_ready, + mock_finish, + mock_drop_db, + mock_event_loop, + mock_stringify, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + """Failure during ingestion (before set_provider_graph_data_ready(False)) + must NOT flip graph_data_ready to True for providers that never had data.""" + 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_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom")) + + with ( + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ), + 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=ingestion_fn, + ), + ): + with pytest.raises(RuntimeError, match="ingestion boom"): + attack_paths_run(str(tenant.id), str(scan.id), "task-456") + + # Gate was never applied, so recovery must not flip anything to True + mock_set_provider_graph_data_ready.assert_not_called() + mock_set_graph_data_ready.assert_not_called() + @patch( "tasks.jobs.attack_paths.scan.utils.stringify_exception", return_value="Cartography failed: ingestion boom", @@ -290,7 +392,7 @@ class TestAttackPathsRun: @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") @patch("tasks.jobs.attack_paths.scan.findings.analysis") @patch("tasks.jobs.attack_paths.scan.internet.analysis") - @patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes") + @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.scan.graph_database.create_database") @@ -373,6 +475,465 @@ class TestAttackPathsRun: assert failure_args[1] == StateChoices.FAILED assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"} + @patch( + "tasks.jobs.attack_paths.scan.utils.stringify_exception", + return_value="Attack Paths scan failed: drop failed", + ) + @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.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_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") + @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") + @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") + @patch( + "tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", + side_effect=RuntimeError("drop failed"), + ) + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.internet.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @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.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", + return_value="bolt://neo4j", + ) + @patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ) + @patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_failure_after_gate_before_drop_restores_graph_data_ready( + self, + mock_init_provider, + mock_get_uri, + mock_create_db, + mock_clear_cache, + mock_cartography_indexes, + mock_cartography_analysis, + mock_cartography_ontology, + mock_findings_indexes, + mock_findings_analysis, + mock_internet_analysis, + mock_sync_indexes, + mock_drop_subgraph, + mock_sync, + mock_starting, + mock_update_progress, + mock_set_provider_graph_data_ready, + mock_set_graph_data_ready, + mock_finish, + mock_drop_db, + mock_event_loop, + mock_stringify, + 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_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + + with ( + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + side_effect=["db-scan-id", "tenant-db"], + ), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ), + 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={}), + ), + ): + with pytest.raises(RuntimeError, match="drop failed"): + 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), + ] + + @patch( + "tasks.jobs.attack_paths.scan.utils.stringify_exception", + return_value="Attack Paths scan failed: sync failed", + ) + @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.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_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") + @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") + @patch( + "tasks.jobs.attack_paths.scan.sync.sync_graph", + side_effect=RuntimeError("sync failed"), + ) + @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.internet.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @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.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", + return_value="bolt://neo4j", + ) + @patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ) + @patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_failure_after_drop_before_sync_leaves_graph_data_ready_false( + self, + mock_init_provider, + mock_get_uri, + mock_create_db, + mock_clear_cache, + mock_cartography_indexes, + mock_cartography_analysis, + mock_cartography_ontology, + mock_findings_indexes, + mock_findings_analysis, + mock_internet_analysis, + mock_sync_indexes, + mock_drop_subgraph, + mock_sync, + mock_starting, + mock_update_progress, + mock_set_provider_graph_data_ready, + mock_set_graph_data_ready, + mock_finish, + mock_drop_db, + mock_event_loop, + mock_stringify, + 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_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + + with ( + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + side_effect=["db-scan-id", "tenant-db"], + ), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ), + 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={}), + ), + ): + with pytest.raises(RuntimeError, match="sync failed"): + attack_paths_run(str(tenant.id), str(scan.id), "task-456") + + # 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 + ) + + @patch( + "tasks.jobs.attack_paths.scan.utils.stringify_exception", + return_value="Attack Paths scan failed: flag failed", + ) + @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.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_graph_data_ready", + side_effect=[RuntimeError("flag failed"), None], + ) + @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") + @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") + @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") + @patch("tasks.jobs.attack_paths.scan.graph_database.drop_subgraph") + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.internet.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @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.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", + return_value="bolt://neo4j", + ) + @patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ) + @patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_failure_after_sync_restores_graph_data_ready( + self, + mock_init_provider, + mock_get_uri, + mock_create_db, + mock_clear_cache, + mock_cartography_indexes, + mock_cartography_analysis, + mock_cartography_ontology, + mock_findings_indexes, + mock_findings_analysis, + mock_internet_analysis, + mock_sync_indexes, + mock_drop_subgraph, + mock_sync, + mock_starting, + mock_update_progress, + mock_set_provider_graph_data_ready, + mock_set_graph_data_ready, + mock_finish, + mock_drop_db, + mock_event_loop, + mock_stringify, + 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_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + + with ( + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + side_effect=["db-scan-id", "tenant-db"], + ), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ), + 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={}), + ), + ): + with pytest.raises(RuntimeError, match="flag failed"): + attack_paths_run(str(tenant.id), str(scan.id), "task-456") + + # sync completed: first call (normal path) raised, recovery retried and succeeded + assert mock_set_graph_data_ready.call_args_list == [ + call(attack_paths_scan, True), + call(attack_paths_scan, True), + ] + # 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 + ) + + @patch( + "tasks.jobs.attack_paths.scan.utils.stringify_exception", + return_value="Attack Paths scan failed: drop failed", + ) + @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.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_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready") + @patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress") + @patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan") + @patch("tasks.jobs.attack_paths.scan.sync.sync_graph") + @patch( + "tasks.jobs.attack_paths.scan.graph_database.drop_subgraph", + side_effect=RuntimeError("drop failed"), + ) + @patch("tasks.jobs.attack_paths.scan.indexes.create_sync_indexes") + @patch("tasks.jobs.attack_paths.scan.internet.analysis") + @patch("tasks.jobs.attack_paths.scan.findings.analysis") + @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.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", + return_value="bolt://neo4j", + ) + @patch( + "tasks.jobs.attack_paths.scan.initialize_prowler_provider", + return_value=MagicMock(_enabled_regions=["us-east-1"]), + ) + @patch( + "tasks.jobs.attack_paths.scan.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_recovery_failure_does_not_suppress_original_exception( + self, + mock_init_provider, + mock_get_uri, + mock_create_db, + mock_clear_cache, + mock_cartography_indexes, + mock_cartography_analysis, + mock_cartography_ontology, + mock_findings_indexes, + mock_findings_analysis, + mock_internet_analysis, + mock_sync_indexes, + mock_drop_subgraph, + mock_sync, + mock_starting, + mock_update_progress, + mock_set_provider_graph_data_ready, + mock_set_graph_data_ready, + mock_finish, + mock_drop_db, + mock_event_loop, + mock_stringify, + 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, + ) + + # Recovery itself fails on the second call (True) + mock_set_provider_graph_data_ready.side_effect = [ + None, + RuntimeError("recovery boom"), + ] + + mock_session = MagicMock() + session_ctx = MagicMock() + session_ctx.__enter__.return_value = mock_session + session_ctx.__exit__.return_value = False + + with ( + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_database_name", + side_effect=["db-scan-id", "tenant-db"], + ), + patch( + "tasks.jobs.attack_paths.scan.graph_database.get_session", + return_value=session_ctx, + ), + 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={}), + ), + ): + # Original exception propagates despite recovery failure + with pytest.raises(RuntimeError, match="drop failed"): + attack_paths_run(str(tenant.id), str(scan.id), "task-456") + def test_run_returns_early_for_unsupported_provider(self, tenants_fixture): tenant = tenants_fixture[0] provider = Provider.objects.create( @@ -421,9 +982,7 @@ class TestFailAttackPathsScan: def test_marks_executing_scan_as_failed( self, tenants_fixture, providers_fixture, scans_fixture ): - from tasks.jobs.attack_paths.db_utils import ( - fail_attack_paths_scan, - ) + from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan tenant = tenants_fixture[0] provider = providers_fixture[0] @@ -448,27 +1007,24 @@ class TestFailAttackPathsScan: patch( "tasks.jobs.attack_paths.db_utils.graph_database.drop_database" ) as mock_drop_db, - patch( - "tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan" - ) as mock_finish, + patch("tasks.jobs.attack_paths.db_utils.recover_graph_data_ready"), ): fail_attack_paths_scan(str(tenant.id), str(scan.id), "setup exploded") mock_retrieve.assert_called_once_with(str(tenant.id), str(scan.id)) expected_tmp_db = f"db-tmp-scan-{str(attack_paths_scan.id).lower()}" mock_drop_db.assert_called_once_with(expected_tmp_db) - mock_finish.assert_called_once_with( - attack_paths_scan, - StateChoices.FAILED, - {"global_error": "setup exploded"}, - ) + + attack_paths_scan.refresh_from_db() + assert attack_paths_scan.state == StateChoices.FAILED + assert attack_paths_scan.ingestion_exceptions == { + "global_error": "setup exploded" + } def test_drops_temp_database_even_when_drop_fails( self, tenants_fixture, providers_fixture, scans_fixture ): - from tasks.jobs.attack_paths.db_utils import ( - fail_attack_paths_scan, - ) + from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan tenant = tenants_fixture[0] provider = providers_fixture[0] @@ -494,24 +1050,17 @@ class TestFailAttackPathsScan: "tasks.jobs.attack_paths.db_utils.graph_database.drop_database", side_effect=Exception("Neo4j unreachable"), ), - patch( - "tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan" - ) as mock_finish, + patch("tasks.jobs.attack_paths.db_utils.recover_graph_data_ready"), ): fail_attack_paths_scan(str(tenant.id), str(scan.id), "setup exploded") - mock_finish.assert_called_once_with( - attack_paths_scan, - StateChoices.FAILED, - {"global_error": "setup exploded"}, - ) + attack_paths_scan.refresh_from_db() + assert attack_paths_scan.state == StateChoices.FAILED def test_skips_already_failed_scan( self, tenants_fixture, providers_fixture, scans_fixture ): - from tasks.jobs.attack_paths.db_utils import ( - fail_attack_paths_scan, - ) + from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan tenant = tenants_fixture[0] provider = providers_fixture[0] @@ -536,34 +1085,127 @@ class TestFailAttackPathsScan: patch( "tasks.jobs.attack_paths.db_utils.graph_database.drop_database" ) as mock_drop_db, - patch( - "tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan" - ) as mock_finish, ): fail_attack_paths_scan(str(tenant.id), str(scan.id), "setup exploded") - mock_drop_db.assert_not_called() - mock_finish.assert_not_called() + mock_drop_db.assert_called_once() + + attack_paths_scan.refresh_from_db() + assert attack_paths_scan.state == StateChoices.FAILED def test_skips_when_no_scan_found(self, tenants_fixture): - from tasks.jobs.attack_paths.db_utils import ( - fail_attack_paths_scan, - ) + from tasks.jobs.attack_paths.db_utils import fail_attack_paths_scan tenant = tenants_fixture[0] + with patch( + "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", + return_value=None, + ): + 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 + ): + from tasks.jobs.attack_paths.db_utils import fail_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() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.EXECUTING, + ) + with ( patch( "tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan", - return_value=None, + 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.finish_attack_paths_scan" - ) as mock_finish, + "tasks.jobs.attack_paths.db_utils.set_provider_graph_data_ready" + ) as mock_set_ready, ): - fail_attack_paths_scan(str(tenant.id), "nonexistent", "setup exploded") + fail_attack_paths_scan(str(tenant.id), str(scan.id), "worker died") - mock_finish.assert_not_called() + 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 + ): + from tasks.jobs.attack_paths.db_utils import fail_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() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.EXECUTING, + ) + + 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, + ): + fail_attack_paths_scan(str(tenant.id), str(scan.id), "worker died") + + mock_set_ready.assert_not_called() + + def test_recover_graph_data_ready_never_raises( + self, tenants_fixture, providers_fixture, scans_fixture + ): + from tasks.jobs.attack_paths.db_utils import recover_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() + + attack_paths_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.EXECUTING, + ) + + with patch( + "tasks.jobs.attack_paths.db_utils.graph_database.has_provider_data", + side_effect=Exception("Neo4j unreachable"), + ): + # Should not raise + recover_graph_data_ready(attack_paths_scan) class TestAttackPathsScanRLSTaskOnFailure: @@ -609,7 +1251,7 @@ class TestAttackPathsFindingsHelpers: def test_create_findings_indexes_executes_all_statements(self): mock_session = MagicMock() with patch("tasks.jobs.attack_paths.indexes.run_write_query") as mock_run_write: - findings_module.create_findings_indexes(mock_session) + indexes_module.create_findings_indexes(mock_session) from tasks.jobs.attack_paths.indexes import FINDINGS_INDEX_STATEMENTS @@ -623,16 +1265,10 @@ class TestAttackPathsFindingsHelpers: provider.provider = Provider.ProviderChoices.AWS provider.save() - # Create mock Finding objects with to_dict() method - mock_finding_1 = MagicMock() - mock_finding_1.to_dict.return_value = {"id": "1", "resource_uid": "r-1"} - mock_finding_2 = MagicMock() - mock_finding_2.to_dict.return_value = {"id": "2", "resource_uid": "r-2"} - - # Create a generator that yields two batches of Finding instances + # Create a generator that yields two batches of dicts (pre-converted) def findings_generator(): - yield [mock_finding_1] - yield [mock_finding_2] + yield [{"id": "1", "resource_uid": "r-1"}] + yield [{"id": "2", "resource_uid": "r-2"}] config = SimpleNamespace(update_tag=12345) mock_session = MagicMock() @@ -648,7 +1284,7 @@ class TestAttackPathsFindingsHelpers: ), patch( "tasks.jobs.attack_paths.findings.get_provider_resource_label", - return_value="AWSResource", + return_value="_AWSResource", ), ): findings_module.load_findings( @@ -677,7 +1313,6 @@ class TestAttackPathsFindingsHelpers: assert mock_session.run.call_count == 2 params = mock_session.run.call_args.args[1] - assert params["provider_uid"] == str(provider.uid) assert params["last_updated"] == config.update_tag def test_stream_findings_with_resources_returns_latest_scan_data( @@ -779,17 +1414,17 @@ class TestAttackPathsFindingsHelpers: assert len(findings_data) == 1 finding_result = findings_data[0] - assert finding_result.id == str(finding.id) - assert finding_result.resource_uid == resource.uid - assert finding_result.check_title == "Check title" - assert finding_result.scan_id == str(latest_scan.id) + assert finding_result["id"] == str(finding.id) + assert finding_result["resource_uid"] == resource.uid + assert finding_result["check_title"] == "Check title" + assert finding_result["scan_id"] == str(latest_scan.id) def test_enrich_batch_with_resources_single_resource( self, tenants_fixture, providers_fixture, ): - """One finding + one resource = one output Finding instance""" + """One finding + one resource = one output dict""" tenant = tenants_fixture[0] provider = providers_fixture[0] provider.provider = Provider.ProviderChoices.AWS @@ -863,16 +1498,16 @@ class TestAttackPathsFindingsHelpers: ) assert len(result) == 1 - assert result[0].resource_uid == resource.uid - assert result[0].id == str(finding.id) - assert result[0].status == "FAIL" + assert result[0]["resource_uid"] == resource.uid + assert result[0]["id"] == str(finding.id) + assert result[0]["status"] == "FAIL" def test_enrich_batch_with_resources_multiple_resources( self, tenants_fixture, providers_fixture, ): - """One finding + three resources = three output Finding instances""" + """One finding + three resources = three output dicts""" tenant = tenants_fixture[0] provider = providers_fixture[0] provider.provider = Provider.ProviderChoices.AWS @@ -951,13 +1586,13 @@ class TestAttackPathsFindingsHelpers: ) assert len(result) == 3 - result_resource_uids = {r.resource_uid for r in result} + result_resource_uids = {r["resource_uid"] for r in result} assert result_resource_uids == {r.uid for r in resources} # All should have same finding data for r in result: - assert r.id == str(finding.id) - assert r.status == "FAIL" + assert r["id"] == str(finding.id) + assert r["status"] == "FAIL" def test_enrich_batch_with_resources_no_resources_skips( self, @@ -1034,16 +1669,12 @@ class TestAttackPathsFindingsHelpers: provider.save() scan_id = "some-scan-id" - with ( - patch("tasks.jobs.attack_paths.findings.rls_transaction") as mock_rls, - patch("tasks.jobs.attack_paths.findings.Finding") as mock_finding, - ): + with patch("tasks.jobs.attack_paths.findings.rls_transaction") as mock_rls: # Create generator but don't iterate findings_module.stream_findings_with_resources(provider, scan_id) # Nothing should be called yet mock_rls.assert_not_called() - mock_finding.objects.filter.assert_not_called() def test_load_findings_empty_generator(self, providers_fixture): """Empty generator should not call neo4j""" @@ -1069,7 +1700,7 @@ class TestAttackPathsFindingsHelpers: ), patch( "tasks.jobs.attack_paths.findings.get_provider_resource_label", - return_value="AWSResource", + return_value="_AWSResource", ), ): findings_module.load_findings(mock_session, empty_gen(), provider, config) @@ -1077,19 +1708,8 @@ class TestAttackPathsFindingsHelpers: mock_session.run.assert_not_called() -class TestProviderConfigAccessors: - def test_get_deprecated_provider_resource_label_known_provider(self): - assert get_deprecated_provider_resource_label("aws") == "AWSResource" - - def test_get_deprecated_provider_resource_label_unknown_provider(self): - assert ( - get_deprecated_provider_resource_label("unknown") - == "UnknownProviderResource" - ) - - class TestAddResourceLabel: - def test_add_resource_label_applies_both_labels(self): + def test_add_resource_label_applies_private_label(self): mock_session = MagicMock() first_result = MagicMock() @@ -1104,40 +1724,228 @@ class TestAddResourceLabel: assert mock_session.run.call_count == 2 query = mock_session.run.call_args_list[0].args[0] assert "_AWSResource" in query - assert "AWSResource" in query + assert "AWSResource" not in query.replace("_AWSResource", "") + + +def _make_session_ctx(session, call_order=None, name=None): + """Create a mock context manager wrapping a mock session.""" + ctx = MagicMock() + if call_order is not None and name is not None: + ctx.__enter__ = MagicMock( + side_effect=lambda: (call_order.append(f"{name}:enter"), session)[1] + ) + ctx.__exit__ = MagicMock( + side_effect=lambda *a: (call_order.append(f"{name}:exit"), False)[1] + ) + else: + ctx.__enter__ = MagicMock(return_value=session) + ctx.__exit__ = MagicMock(return_value=False) + return ctx class TestSyncNodes: - def test_sync_nodes_adds_both_labels(self): - mock_source_session = MagicMock() - mock_target_session = MagicMock() - + def test_sync_nodes_adds_private_label(self): row = { "internal_id": 1, "element_id": "elem-1", "labels": ["SomeLabel"], "props": {"key": "value"}, } - mock_source_session.run.side_effect = [[row], []] - source_ctx = MagicMock() - source_ctx.__enter__ = MagicMock(return_value=mock_source_session) - source_ctx.__exit__ = MagicMock(return_value=False) - - target_ctx = MagicMock() - target_ctx.__enter__ = MagicMock(return_value=mock_target_session) - target_ctx.__exit__ = MagicMock(return_value=False) + mock_source_1 = MagicMock() + mock_source_1.run.return_value = [row] + mock_target = MagicMock() + mock_source_2 = MagicMock() + mock_source_2.run.return_value = [] with patch( "tasks.jobs.attack_paths.sync.graph_database.get_session", - side_effect=[source_ctx, target_ctx], + 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", "prov-1") + total = sync_module.sync_nodes( + "source-db", "target-db", "tenant-1", "prov-1" + ) assert total == 1 - query = mock_target_session.run.call_args.args[0] + query = mock_target.run.call_args.args[0] assert "_ProviderResource" in query - assert "ProviderResource" in query + assert "_Tenant_tenant1" in query + assert "_Provider_prov1" in query + + def test_sync_nodes_source_closes_before_target_opens(self): + row = { + "internal_id": 1, + "element_id": "elem-1", + "labels": ["SomeLabel"], + "props": {"key": "value"}, + } + + call_order = [] + + src_1 = MagicMock() + src_1.run.return_value = [row] + tgt = MagicMock() + src_2 = MagicMock() + src_2.run.return_value = [] + + 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") + + assert call_order.index("source1:exit") < call_order.index("target:enter") + + def test_sync_nodes_pagination_with_batch_size_1(self): + row_a = { + "internal_id": 1, + "element_id": "elem-1", + "labels": ["LabelA"], + "props": {"a": 1}, + } + row_b = { + "internal_id": 2, + "element_id": "elem-2", + "labels": ["LabelB"], + "props": {"b": 2}, + } + + src_1 = MagicMock() + src_1.run.return_value = [row_a] + src_2 = MagicMock() + src_2.run.return_value = [row_b] + src_3 = MagicMock() + src_3.run.return_value = [] + tgt_1 = MagicMock() + tgt_2 = 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") + + assert total == 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_empty_source_returns_zero(self): + src = MagicMock() + src.run.return_value = [] + + 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") + + assert total == 0 + assert mock_get_session.call_count == 1 + + +class TestSyncRelationships: + def test_sync_relationships_source_closes_before_target_opens(self): + row = { + "internal_id": 1, + "rel_type": "HAS", + "start_element_id": "s-1", + "end_element_id": "e-1", + "props": {}, + } + + call_order = [] + + src_1 = MagicMock() + src_1.run.return_value = [row] + tgt = MagicMock() + src_2 = MagicMock() + src_2.run.return_value = [] + + 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") + + assert call_order.index("source1:exit") < call_order.index("target:enter") + + def test_sync_relationships_pagination_with_batch_size_1(self): + row_a = { + "internal_id": 1, + "rel_type": "HAS", + "start_element_id": "s-1", + "end_element_id": "e-1", + "props": {"a": 1}, + } + row_b = { + "internal_id": 2, + "rel_type": "CONNECTS", + "start_element_id": "s-2", + "end_element_id": "e-2", + "props": {"b": 2}, + } + + src_1 = MagicMock() + src_1.run.return_value = [row_a] + src_2 = MagicMock() + src_2.run.return_value = [row_b] + src_3 = MagicMock() + src_3.run.return_value = [] + tgt_1 = MagicMock() + tgt_2 = 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") + + assert total == 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_empty_source_returns_zero(self): + src = MagicMock() + src.run.return_value = [] + + 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") + + assert total == 0 + assert mock_get_session.call_count == 1 class TestInternetAnalysis: @@ -1494,3 +2302,374 @@ class TestAttackPathsDbUtilsGraphDataReady: ap_scan_b.refresh_from_db() assert ap_scan_a.graph_data_ready is False assert ap_scan_b.graph_data_ready is True + + +@pytest.mark.django_db +class TestCleanupStaleAttackPathsScans: + def _create_executing_scan( + self, tenant, provider, scan=None, started_at=None, worker=None + ): + """Helper to create an EXECUTING AttackPathsScan with optional Task+TaskResult.""" + ap_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + scan=scan, + state=StateChoices.EXECUTING, + started_at=started_at or datetime.now(tz=timezone.utc), + ) + + task_result = None + if worker is not None: + task_result = TaskResult.objects.create( + task_id=str(ap_scan.id), + task_name="attack-paths-scan-perform", + status="STARTED", + worker=worker, + ) + task = Task.objects.create( + id=task_result.task_id, + task_runner_task=task_result, + tenant_id=tenant.id, + ) + ap_scan.task = task + ap_scan.save(update_fields=["task_id"]) + + return ap_scan, task_result + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) + def test_cleans_up_scan_with_dead_worker( + self, + mock_alive, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + # Recent scan — should still be cleaned up because worker is dead + ap_scan, task_result = self._create_executing_scan( + tenant, provider, worker="dead-worker@host" + ) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 1 + assert str(ap_scan.id) in result["scan_ids"] + mock_drop_db.assert_called_once() + mock_recover.assert_called_once() + + ap_scan.refresh_from_db() + assert ap_scan.state == StateChoices.FAILED + assert ap_scan.progress == 100 + assert ap_scan.completed_at is not None + assert ap_scan.ingestion_exceptions == { + "global_error": "Worker dead — cleaned up by periodic task" + } + + task_result.refresh_from_db() + assert task_result.status == "FAILURE" + assert task_result.date_done is not None + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._revoke_task") + @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=True) + def test_revokes_and_cleans_scan_exceeding_threshold_on_live_worker( + self, + mock_alive, + mock_revoke, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + old_start = datetime.now(tz=timezone.utc) - timedelta(hours=49) + ap_scan, task_result = self._create_executing_scan( + tenant, provider, started_at=old_start, worker="live-worker@host" + ) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 1 + mock_revoke.assert_called_once_with(task_result) + mock_recover.assert_called_once() + + ap_scan.refresh_from_db() + assert ap_scan.state == StateChoices.FAILED + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=True) + def test_ignores_recent_executing_scans_on_live_worker( + self, + mock_alive, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + # Recent scan on live worker — should be skipped + self._create_executing_scan(tenant, provider, worker="live-worker@host") + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + mock_drop_db.assert_not_called() + mock_recover.assert_not_called() + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_ignores_completed_and_failed_scans( + self, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + state=StateChoices.COMPLETED, + ) + AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + state=StateChoices.FAILED, + ) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 0 + mock_drop_db.assert_not_called() + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch( + "tasks.jobs.attack_paths.cleanup.graph_database.drop_database", + side_effect=Exception("Neo4j unreachable"), + ) + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) + def test_handles_drop_database_failure_gracefully( + self, + mock_alive, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + self._create_executing_scan(tenant, provider, worker="dead-worker@host") + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 1 + mock_drop_db.assert_called_once() + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) + def test_cross_tenant_cleanup( + self, + mock_alive, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant1 = tenants_fixture[0] + tenant2 = tenants_fixture[1] + provider1 = providers_fixture[0] + provider1.provider = Provider.ProviderChoices.AWS + provider1.save() + + provider2 = Provider.objects.create( + provider="aws", + uid="999888777666", + alias="aws_tenant2", + tenant_id=tenant2.id, + ) + + ap_scan1, _ = self._create_executing_scan( + tenant1, provider1, worker="dead-worker-1@host" + ) + ap_scan2, _ = self._create_executing_scan( + tenant2, provider2, worker="dead-worker-2@host" + ) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 2 + assert mock_recover.call_count == 2 + + ap_scan1.refresh_from_db() + ap_scan2.refresh_from_db() + assert ap_scan1.state == StateChoices.FAILED + assert ap_scan2.state == StateChoices.FAILED + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) + def test_recovers_graph_data_ready_for_stale_scan( + self, + mock_alive, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + ap_scan, _ = self._create_executing_scan( + tenant, provider, worker="dead-worker@host" + ) + + cleanup_stale_attack_paths_scans() + + mock_recover.assert_called_once() + recovered_scan = mock_recover.call_args[0][0] + assert recovered_scan.id == ap_scan.id + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + def test_fallback_to_time_heuristic_when_no_worker_field( + self, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + # Old scan with no Task/TaskResult + old_start = datetime.now(tz=timezone.utc) - timedelta(hours=49) + ap_scan = AttackPathsScan.objects.create( + tenant_id=tenant.id, + provider=provider, + state=StateChoices.EXECUTING, + started_at=old_start, + ) + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 1 + + ap_scan.refresh_from_db() + assert ap_scan.state == StateChoices.FAILED + + @patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready") + @patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database") + @patch( + "tasks.jobs.attack_paths.cleanup.rls_transaction", + new=lambda *args, **kwargs: nullcontext(), + ) + @patch("tasks.jobs.attack_paths.cleanup._is_worker_alive", return_value=False) + def test_shared_worker_is_pinged_only_once( + self, + mock_alive, + mock_drop_db, + mock_recover, + tenants_fixture, + providers_fixture, + scans_fixture, + ): + from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans + + tenant = tenants_fixture[0] + provider = providers_fixture[0] + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + # Two scans on the same dead worker + self._create_executing_scan(tenant, provider, worker="shared-worker@host") + self._create_executing_scan(tenant, provider, worker="shared-worker@host") + + result = cleanup_stale_attack_paths_scans() + + assert result["cleaned_up_count"] == 2 + # Worker should be pinged exactly once — cache prevents second ping + mock_alive.assert_called_once_with("shared-worker@host") diff --git a/api/src/backend/tasks/tests/test_reports.py b/api/src/backend/tasks/tests/test_reports.py index 530e0af472..858f4c06ca 100644 --- a/api/src/backend/tasks/tests/test_reports.py +++ b/api/src/backend/tasks/tests/test_reports.py @@ -29,7 +29,7 @@ from tasks.jobs.threatscore_utils import ( _load_findings_for_requirement_checks, ) -from api.models import Finding, StatusChoices +from api.models import Finding, Resource, ResourceFindingMapping, StatusChoices from prowler.lib.check.models import Severity matplotlib.use("Agg") # Use non-interactive backend for tests @@ -39,43 +39,50 @@ matplotlib.use("Agg") # Use non-interactive backend for tests class TestAggregateRequirementStatistics: """Test suite for _aggregate_requirement_statistics_from_database function.""" + def _create_finding_with_resource( + self, tenant, scan, uid, check_id, status, severity=Severity.high + ): + """Helper to create a finding linked to a resource (matching scan processing behavior).""" + finding = Finding.objects.create( + tenant_id=tenant.id, + scan=scan, + uid=uid, + check_id=check_id, + status=status, + severity=severity, + impact=severity, + check_metadata={}, + raw_result={}, + ) + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=scan.provider, + uid=f"resource-{uid}", + name=f"resource-{uid}", + region="us-east-1", + service="test", + type="test::resource", + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, + finding=finding, + resource=resource, + ) + return finding + def test_aggregates_findings_correctly(self, tenants_fixture, scans_fixture): """Verify correct pass/total counts per check are aggregated from database.""" tenant = tenants_fixture[0] scan = scans_fixture[0] - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-1", - check_id="check_1", - status=StatusChoices.PASS, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, + self._create_finding_with_resource( + tenant, scan, "finding-1", "check_1", StatusChoices.PASS ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-2", - check_id="check_1", - status=StatusChoices.FAIL, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, + self._create_finding_with_resource( + tenant, scan, "finding-2", "check_1", StatusChoices.FAIL ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-3", - check_id="check_2", - status=StatusChoices.PASS, - severity=Severity.medium, - impact=Severity.medium, - check_metadata={}, - raw_result={}, + self._create_finding_with_resource( + tenant, scan, "finding-3", "check_2", StatusChoices.PASS, Severity.medium ) result = _aggregate_requirement_statistics_from_database( @@ -106,27 +113,11 @@ class TestAggregateRequirementStatistics: tenant = tenants_fixture[0] scan = scans_fixture[0] - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-1", - check_id="check_1", - status=StatusChoices.FAIL, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, + self._create_finding_with_resource( + tenant, scan, "finding-1", "check_1", StatusChoices.FAIL ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-2", - check_id="check_1", - status=StatusChoices.FAIL, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, + self._create_finding_with_resource( + tenant, scan, "finding-2", "check_1", StatusChoices.FAIL ) result = _aggregate_requirement_statistics_from_database( @@ -142,16 +133,12 @@ class TestAggregateRequirementStatistics: scan = scans_fixture[0] for i in range(5): - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid=f"finding-{i}", - check_id="check_1", - status=StatusChoices.PASS if i % 2 == 0 else StatusChoices.FAIL, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, + self._create_finding_with_resource( + tenant, + scan, + f"finding-{i}", + "check_1", + StatusChoices.PASS if i % 2 == 0 else StatusChoices.FAIL, ) result = _aggregate_requirement_statistics_from_database( @@ -162,11 +149,54 @@ class TestAggregateRequirementStatistics: assert result["check_1"]["total"] == 5 def test_mixed_statuses(self, tenants_fixture, scans_fixture): - """Verify MANUAL status is counted in total but not passed.""" + """Verify MANUAL status is not counted in total or passed.""" tenant = tenants_fixture[0] scan = scans_fixture[0] - Finding.objects.create( + self._create_finding_with_resource( + tenant, scan, "finding-1", "check_1", StatusChoices.PASS + ) + self._create_finding_with_resource( + tenant, scan, "finding-2", "check_1", StatusChoices.MANUAL + ) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + # MANUAL findings are excluded from the aggregation query + # since it only counts PASS and FAIL statuses + assert result["check_1"]["passed"] == 1 + assert result["check_1"]["total"] == 1 + + def test_skips_aggregation_for_deleted_provider( + self, tenants_fixture, scans_fixture + ): + """Verify aggregation returns empty when the scan's provider is soft-deleted.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + self._create_finding_with_resource( + tenant, scan, "finding-1", "check_1", StatusChoices.PASS + ) + + # Soft-delete the provider + provider = scan.provider + provider.is_deleted = True + provider.save(update_fields=["is_deleted"]) + + result = _aggregate_requirement_statistics_from_database( + str(tenant.id), str(scan.id) + ) + + assert result == {} + + def test_multiple_resources_no_double_count(self, tenants_fixture, scans_fixture): + """Verify a finding with multiple resources is only counted once.""" + tenant = tenants_fixture[0] + scan = scans_fixture[0] + + finding = Finding.objects.create( tenant_id=tenant.id, scan=scan, uid="finding-1", @@ -177,24 +207,27 @@ class TestAggregateRequirementStatistics: check_metadata={}, raw_result={}, ) - Finding.objects.create( - tenant_id=tenant.id, - scan=scan, - uid="finding-2", - check_id="check_1", - status=StatusChoices.MANUAL, - severity=Severity.high, - impact=Severity.high, - check_metadata={}, - raw_result={}, - ) + # Link two resources to the same finding + for i in range(2): + resource = Resource.objects.create( + tenant_id=tenant.id, + provider=scan.provider, + uid=f"resource-{i}", + name=f"resource-{i}", + region="us-east-1", + service="test", + type="test::resource", + ) + ResourceFindingMapping.objects.create( + tenant_id=tenant.id, + finding=finding, + resource=resource, + ) result = _aggregate_requirement_statistics_from_database( str(tenant.id), str(scan.id) ) - # MANUAL findings are excluded from the aggregation query - # since it only counts PASS and FAIL statuses assert result["check_1"]["passed"] == 1 assert result["check_1"]["total"] == 1 diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index ac4d5474dc..716033684b 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -1411,7 +1411,9 @@ class TestProcessFindingMicroBatch: assert created_finding.status == StatusChoices.PASS assert created_finding.delta == Finding.DeltaChoices.NEW assert created_finding.muted is False - assert created_finding.check_metadata == finding.metadata + expected_metadata = {**finding.metadata, "compliance": finding.compliance} + assert created_finding.check_metadata == expected_metadata + assert created_finding.check_metadata["compliance"] == finding.compliance assert created_finding.resource_regions == [finding.region] assert created_finding.resource_services == [finding.service_name] assert created_finding.resource_types == [finding.resource_type] diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 3a58118c62..8469b7db09 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -20,6 +20,7 @@ from tasks.tasks import ( generate_outputs_task, perform_attack_paths_scan_task, perform_scheduled_scan_task, + reaggregate_all_finding_group_summaries_task, refresh_lighthouse_provider_models_task, s3_integration_task, security_hub_integration_task, @@ -2351,3 +2352,47 @@ class TestPerformScheduledScanTask: ).count() == 1 ) + + +@pytest.mark.django_db +class TestReaggregateAllFindingGroupSummaries: + def setup_method(self): + self.tenant_id = str(uuid.uuid4()) + + @patch("tasks.tasks.group") + @patch("tasks.tasks.aggregate_finding_group_summaries_task") + @patch("tasks.tasks.Scan.objects.filter") + def test_dispatches_subtasks_for_each_provider( + self, mock_scan_filter, mock_agg_task, mock_group + ): + scan_id_1 = uuid.uuid4() + scan_id_2 = uuid.uuid4() + mock_group_result = MagicMock() + mock_group.side_effect = lambda gen: (list(gen), mock_group_result)[1] + + mock_scan_filter.return_value.order_by.return_value.distinct.return_value.values_list.return_value = [ + scan_id_1, + scan_id_2, + ] + + result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id) + + assert result == {"scans_reaggregated": 2} + assert mock_agg_task.si.call_count == 2 + mock_agg_task.si.assert_any_call( + tenant_id=self.tenant_id, scan_id=str(scan_id_1) + ) + mock_agg_task.si.assert_any_call( + tenant_id=self.tenant_id, scan_id=str(scan_id_2) + ) + mock_group_result.apply_async.assert_called_once() + + @patch("tasks.tasks.group") + @patch("tasks.tasks.Scan.objects.filter") + def test_no_completed_scans_skips_dispatch(self, mock_scan_filter, mock_group): + mock_scan_filter.return_value.order_by.return_value.distinct.return_value.values_list.return_value = [] + + result = reaggregate_all_finding_group_summaries_task(tenant_id=self.tenant_id) + + assert result == {"scans_reaggregated": 0} + mock_group.assert_not_called() diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index 40a7fda298..61146e1e1f 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -39,6 +39,9 @@ secrets: POSTGRES_PASSWORD: POSTGRES_DB: # Valkey settings + VALKEY_SCHEME: redis + VALKEY_USERNAME: + VALKEY_PASSWORD: VALKEY_HOST: valkey-headless VALKEY_PORT: "6379" VALKEY_DB: "0" diff --git a/contrib/k8s/helm/prowler-app/.gitignore b/contrib/k8s/helm/prowler-app/.gitignore new file mode 100644 index 0000000000..ee3892e879 --- /dev/null +++ b/contrib/k8s/helm/prowler-app/.gitignore @@ -0,0 +1 @@ +charts/ diff --git a/contrib/k8s/helm/prowler-app/Chart.yaml b/contrib/k8s/helm/prowler-app/Chart.yaml index 5397d43569..672e807f10 100644 --- a/contrib/k8s/helm/prowler-app/Chart.yaml +++ b/contrib/k8s/helm/prowler-app/Chart.yaml @@ -13,6 +13,8 @@ keywords: - gcp - kubernetes maintainers: + - name: Dani + email: andre.gomes@promptlyhealth.com - name: Mihai email: mihai.legat@gmail.com dependencies: diff --git a/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml b/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml index 7778d06731..9b84dd33d6 100644 --- a/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml +++ b/contrib/k8s/helm/prowler-app/templates/api/secret-valkey.yaml @@ -7,6 +7,9 @@ metadata: {{- include "prowler.labels" . | nindent 4 }} type: Opaque stringData: + VALKEY_SCHEME: {{ .Values.valkey.scheme | default "redis" | quote }} + VALKEY_USERNAME: {{ .Values.valkey.username | default "" | quote }} + VALKEY_PASSWORD: {{ .Values.valkey.password | default "" | quote }} VALKEY_HOST: "{{ include "prowler.fullname" . }}-valkey" VALKEY_PORT: "6379" VALKEY_DB: "0" diff --git a/contrib/k8s/helm/prowler-app/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml index 46258351ad..9a162bd67e 100644 --- a/contrib/k8s/helm/prowler-app/values.yaml +++ b/contrib/k8s/helm/prowler-app/values.yaml @@ -529,6 +529,9 @@ postgresql: valkey: # If enabled, it will create a Secret with the following. # Otherwise, create a secret with + # - VALKEY_SCHEME + # - VALKEY_USERNAME + # - VALKEY_PASSWORD # - VALKEY_HOST # - VALKEY_PORT # - VALKEY_DB @@ -558,9 +561,9 @@ neo4j: # Neo4j Configuration (yaml format) config: dbms_security_procedures_allowlist: "apoc.*" - dbms_security_procedures_unrestricted: "apoc.*" + dbms_security_procedures_unrestricted: "" apoc_config: - apoc.export.file.enabled: "true" - apoc.import.file.enabled: "true" + apoc.export.file.enabled: "false" + apoc.import.file.enabled: "false" apoc.import.file.use_neo4j_config: "true" diff --git a/dashboard/__main__.py b/dashboard/__main__.py index 304e4afdf2..664ce3bfa5 100644 --- a/dashboard/__main__.py +++ b/dashboard/__main__.py @@ -21,7 +21,7 @@ print( f"{Fore.GREEN}Loading all CSV files from the folder {folder_path_overview} ...\n{Style.RESET_ALL}" ) cli.show_server_banner = lambda *x: click.echo( - f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler SaaS{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler Open Source{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3:///output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files." + f"{Fore.YELLOW}NOTE:{Style.RESET_ALL} If you are using {Fore.GREEN}{Style.BRIGHT}Prowler Cloud{Style.RESET_ALL} with the S3 integration or that integration \nfrom {Fore.CYAN}{Style.BRIGHT}Prowler CLI{Style.RESET_ALL} and you want to use your data from your S3 bucket,\nrun: `{orange_color}aws s3 cp s3:///output/csv ./output --recursive{Style.RESET_ALL}`\nand then run `prowler dashboard` again to load the new files." ) # Initialize the app - incorporate css diff --git a/dashboard/compliance/rbi_cyber_security_framework_azure.py b/dashboard/compliance/rbi_cyber_security_framework_azure.py new file mode 100644 index 0000000000..cecafbce53 --- /dev/null +++ b/dashboard/compliance/rbi_cyber_security_framework_azure.py @@ -0,0 +1,20 @@ +import warnings + +from dashboard.common_methods import get_section_containers_rbi + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + return get_section_containers_rbi(aux, "REQUIREMENTS_ID") diff --git a/dashboard/compliance/rbi_cyber_security_framework_gcp.py b/dashboard/compliance/rbi_cyber_security_framework_gcp.py new file mode 100644 index 0000000000..cecafbce53 --- /dev/null +++ b/dashboard/compliance/rbi_cyber_security_framework_gcp.py @@ -0,0 +1,20 @@ +import warnings + +from dashboard.common_methods import get_section_containers_rbi + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + return get_section_containers_rbi(aux, "REQUIREMENTS_ID") diff --git a/dashboard/compliance/secnumcloud_3_2_alibabacloud.py b/dashboard/compliance/secnumcloud_3_2_alibabacloud.py new file mode 100644 index 0000000000..2d5517aed6 --- /dev/null +++ b/dashboard/compliance/secnumcloud_3_2_alibabacloud.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_format3 + +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_format3( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/secnumcloud_3_2_azure.py b/dashboard/compliance/secnumcloud_3_2_azure.py new file mode 100644 index 0000000000..2d5517aed6 --- /dev/null +++ b/dashboard/compliance/secnumcloud_3_2_azure.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_format3 + +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_format3( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/secnumcloud_3_2_gcp.py b/dashboard/compliance/secnumcloud_3_2_gcp.py new file mode 100644 index 0000000000..2d5517aed6 --- /dev/null +++ b/dashboard/compliance/secnumcloud_3_2_gcp.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_format3 + +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_format3( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/compliance/secnumcloud_3_2_oraclecloud.py b/dashboard/compliance/secnumcloud_3_2_oraclecloud.py new file mode 100644 index 0000000000..2d5517aed6 --- /dev/null +++ b/dashboard/compliance/secnumcloud_3_2_oraclecloud.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_format3 + +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_format3( + aux, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index 20395539e5..c1da9f611e 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -80,6 +80,8 @@ def load_csv_files(csv_files): result = result.replace("_M65", " - M65") if "ALIBABACLOUD" in result: result = result.replace("_ALIBABACLOUD", " - ALIBABACLOUD") + if "ORACLECLOUD" in result: + result = result.replace("_ORACLECLOUD", " - ORACLECLOUD") results.append(result) unique_results = set(results) diff --git a/docs/developer-guide/check-metadata-guidelines.mdx b/docs/developer-guide/check-metadata-guidelines.mdx index 7bc335c3de..bac16f6fc0 100644 --- a/docs/developer-guide/check-metadata-guidelines.mdx +++ b/docs/developer-guide/check-metadata-guidelines.mdx @@ -211,7 +211,7 @@ Also is important to keep all code examples as short as possible, including the | email-security | Ensures detection and protection against phishing, spam, spoofing, etc. | | forensics-ready | Ensures systems are instrumented to support post-incident investigations. Any digital trace or evidence (logs, volume snapshots, memory dumps, network captures, etc.) preserved immutably and accompanied by integrity guarantees, which can be used in a forensic analysis | | software-supply-chain | Detects or prevents tampering, unauthorized packages, or third-party risks in software supply chain | -| e3 | M365-specific controls enabled by or dependent on an E3 license (e.g., baseline security policies, conditional access) | -| e5 | M365-specific controls enabled by or dependent on an E5 license (e.g., advanced threat protection, audit, DLP, and eDiscovery) | +| e3 | M365 and Azure Entra checks enabled by or dependent on an E3 license (e.g., baseline security policies, conditional access) | +| e5 | M365 and Azure Entra checks enabled by or dependent on an E5 license (e.g., advanced threat protection, audit, DLP, and eDiscovery) | | privilege-escalation | Detects IAM policies or permissions that allow identities to elevate their privileges beyond their intended scope, potentially gaining administrator or higher-level access through specific action combinations | -| ec2-imdsv1 | Identifies EC2 instances using Instance Metadata Service version 1 (IMDSv1), which is vulnerable to SSRF attacks and should be replaced with IMDSv2 for enhanced security | \ No newline at end of file +| ec2-imdsv1 | Identifies EC2 instances using Instance Metadata Service version 1 (IMDSv1), which is vulnerable to SSRF attacks and should be replaced with IMDSv2 for enhanced security | diff --git a/docs/developer-guide/checks.mdx b/docs/developer-guide/checks.mdx index 0c9bdf154e..da469678fa 100644 --- a/docs/developer-guide/checks.mdx +++ b/docs/developer-guide/checks.mdx @@ -315,6 +315,7 @@ The type of resource being audited. This field helps categorize and organize fin - **Kubernetes**: Use types shown under `KIND` from `kubectl api-resources`. - **Oracle Cloud Infrastructure**: Use types from [Oracle Cloud Infrastructure documentation](https://docs.public.oneportal.content.oci.oraclecloud.com/en-us/iaas/Content/Search/Tasks/queryingresources_topic-Listing_Supported_Resource_Types.htm). - **OpenStack**: Use types from [OpenStack Heat resource types](https://docs.openstack.org/heat/latest/template_guide/openstack.html). +- **Alibaba Cloud**: Use types from [Alibaba Cloud ROS resource types](https://www.alibabacloud.com/help/en/ros/developer-reference/list-of-resource-types-by-service). - **Any other provider**: Use `NotDefined` due to lack of standardized resource types in their SDK or documentation. #### ResourceGroup diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx index 11076baa10..2a4aa3abe1 100644 --- a/docs/developer-guide/introduction.mdx +++ b/docs/developer-guide/introduction.mdx @@ -79,7 +79,7 @@ Remember, our community is here to help! If you need guidance, do not hesitate t Before proceeding, ensure the following: - Git is installed. -- Python 3.9 or higher is installed. +- Python 3.10 or higher is installed. - `poetry` is installed to manage dependencies. ### Forking the Prowler Repository diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx index 867dff14d8..0fb3bc549d 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -1249,7 +1249,7 @@ Dependencies ensure that your provider's required libraries are available when P ```toml [tool.poetry.dependencies] -python = "^3.9" +python = ">=3.10,<3.13" # ... other dependencies your-sdk-library = "^1.0.0" # Add your SDK dependency ``` @@ -3406,6 +3406,40 @@ Use existing providers as templates, this will help you to understand better the - **Use Rules**: Use rules to ensure the code generated by AI is following the way of working in Prowler. +--- + +## OCSF Field Requirements for Prowler Cloud Integration + +When implementing a new provider that supports the `--push-to-cloud` feature, specific OCSF fields must be correctly populated to ensure proper findings ingestion into Prowler Cloud. + +### Required OCSF Fields + +The following fields in the OCSF output are critical for successful ingestion: + +| Field | Requirement | Description | +|-------|-------------|-------------| +| `provider_uid` | Must match the UID used when registering the provider in the API | This identifier links findings to the correct provider in Prowler Cloud | +| `provider` | Must be the provider name | The name of the provider (e.g., `aws`, `azure`, `gcp`, `googleworkspace`) | +| `finding_info.uid` | Must be unique | Each finding must have a unique identifier to avoid duplicates | +| `resources.uid` | Must have a value | The resource UID cannot be empty; it identifies the specific resource being assessed | + +### Implementation Reference + +These fields are set in the OCSF output generation. See the [OCSF output implementation](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/outputs/ocsf/ocsf.py) for reference. + +### Validation Checklist + +Before releasing a new provider with `--push-to-cloud` support: + +- [ ] Verify `provider_uid` matches the UID used in the API to register the provider +- [ ] Confirm `provider` field contains the correct provider name +- [ ] Ensure all `finding_info.uid` values are unique across findings +- [ ] Validate that `resources.uid` is populated for every finding + + + Use `python scripts/validate_ocsf_output.py output/*.ocsf.json` to automate these checks. + + ## Checklist for New Providers ### CLI Integration Only diff --git a/docs/developer-guide/test-impact-analysis.mdx b/docs/developer-guide/test-impact-analysis.mdx new file mode 100644 index 0000000000..f2f82fe8cb --- /dev/null +++ b/docs/developer-guide/test-impact-analysis.mdx @@ -0,0 +1,315 @@ +--- +title: 'Test Impact Analysis' +--- + +Test impact analysis (TIA) determines which tests to run based on the files changed in a pull request. Instead of running the full test suite on every pull request, TIA maps changed files to the specific Prowler SDK, API, and end-to-end (E2E) tests that cover them. This approach reduces continuous integration (CI) time and resource usage while maintaining confidence that relevant code paths are tested. + +## Architecture + +### Components + +| Component | Path | Role | +|-----------|------|------| +| Configuration | `.github/test-impact.yml` | Defines ignored, critical, and module path mappings | +| Analysis engine | `.github/scripts/test-impact.py` | Python script that evaluates changed files against the configuration | +| Reusable workflow | `.github/workflows/test-impact-analysis.yml` | GitHub Actions reusable workflow that orchestrates the analysis | +| E2E consumer | `.github/workflows/ui-e2e-tests-v2.yml` | Consumes TIA outputs to run targeted Playwright tests | + +### Flow Diagram + +``` +PR opened/updated + | + v ++-------------------------------+ +| tj-actions/changed-files | Gets list of changed files from PR ++-------------------------------+ + | + v ++-------------------------------+ +| test-impact.py | +| | +| 1. Filter ignored paths | docs/**, *.md, .gitignore, etc. +| 2. Check critical paths | prowler/lib/**, ui/lib/**, .github/workflows/** +| 3. Match modules | Map remaining files to module definitions +| 4. Categorize tests | Split into sdk-tests, api-tests, ui-e2e ++-------------------------------+ + | + v ++-------------------------------+ +| GitHub Actions Outputs | +| | +| run-all: true/false | +| sdk-tests: "tests/providers/aws/**" +| api-tests: "api/src/backend/api/tests/**" +| ui-e2e: "ui/tests/providers/**" +| modules: "sdk-aws,ui-providers" +| has-tests: true/false | +| has-sdk-tests: true/false | +| has-api-tests: true/false | +| has-ui-e2e: true/false | ++-------------------------------+ + | + v ++-------------------------------+ +| Consumer Workflows | +| | +| ui-e2e-tests-v2.yml: | +| - Path resolution pipeline | +| - Playwright execution | ++-------------------------------+ +``` + +## Configuration Reference + +The configuration lives in `.github/test-impact.yml` and contains three sections. + +### `ignored` — Paths That Never Trigger Tests + +Files matching these patterns are filtered out before any analysis takes place. This section is intended for non-code files. + +```yaml +ignored: + paths: + - docs/** + - "*.md" + - .gitignore + - skills/** + - ui/tests/setups/** # E2E auth setup helpers (not runnable tests) +``` + +### `critical` — Paths That Trigger All Tests + +If any changed file matches a critical path, the system short-circuits and outputs `run-all: true`. All downstream consumers then run their complete test suites. + +```yaml +critical: + paths: + - prowler/lib/** # SDK core + - ui/lib/** # UI shared utilities + - ui/playwright.config.ts # Test infrastructure + - .github/workflows/** # CI changes + - .github/test-impact.yml # This config itself +``` + +### `modules` — Path-to-Test Mappings + +Each module maps source file patterns to the tests that cover them. + +```yaml +- name: ui-providers # Unique identifier + match: # Source file glob patterns + - ui/components/providers/** + - ui/actions/providers/** + - ui/app/**/providers/** + - ui/tests/providers/** # Test file changes also trigger themselves + tests: [] # SDK/API unit test patterns (empty for UI modules) + e2e: # Playwright E2E test patterns + - ui/tests/providers/** +``` + +#### Module Schema + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Unique module identifier (for example, `sdk-aws`, `ui-providers`, `api-views`) | +| `match` | `list[glob]` | Source file patterns that trigger this module | +| `tests` | `list[glob]` | Prowler SDK (`tests/`) or API (`api/`) unit test patterns to run | +| `e2e` | `list[glob]` | UI E2E test patterns (`ui/tests/`) to run | + +#### Module Categories + +- **`sdk-*`:** Provider and lib modules. These only produce `tests` output, not `e2e`. +- **`api-*`:** API views, serializers, filters, and role-based access control (RBAC). These produce `tests` and sometimes `e2e` (API changes can affect UI flows). +- **`ui-*`:** UI feature modules. These only produce `e2e` output, not `tests`. + +## Path Resolution Pipeline + +The E2E consumer workflow (`.github/workflows/ui-e2e-tests-v2.yml`, lines 202–253) transforms the `ui-e2e` output from glob patterns into paths that Playwright can execute. This transformation follows a multi-step shell pipeline. + +### Step 1: Check Run Mode + +```bash +if [[ "${RUN_ALL_TESTS}" == "true" ]]; then + pnpm run test:e2e # Run everything, skip pipeline +fi +``` + +### Step 2: Strip the `ui/` Prefix and `**` Suffix + +```bash +# "ui/tests/providers/**" -> "tests/providers/" +TEST_PATHS=$(echo "$E2E_TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g') +``` + +### Step 3: Filter Out Setup Paths + +```bash +# Remove auth setup helpers (not runnable test suites) +TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/') +``` + +### Step 4: Safety Net for Bare `tests/` + +If the pattern `ui/tests/**` was present in the output (from a critical path or a broad module like `ui-shadcn`), it resolves to bare `tests/` after stripping. This would cause Playwright to discover setup files in `tests/setups/`, so it gets expanded instead: + +```bash +if echo "$TEST_PATHS" | grep -qx 'tests/'; then + # Expand to specific subdirs, excluding tests/setups/ + for dir in tests/*/; do + [[ "$dir" == "tests/setups/" ]] && continue + SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}" + done +fi +``` + +### Step 5: Empty Directory Check + +Directories that do not contain any `.spec.ts` or `.test.ts` files are skipped. This handles forward-looking patterns where a module is configured but tests have not been written yet. + +```bash +if find "$p" -name '*.spec.ts' -o -name '*.test.ts' | head -1 | grep -q .; then + VALID_PATHS="${VALID_PATHS}${p}" +else + echo "Skipping empty test directory: $p" +fi +``` + +### Step 6: Execute Playwright + +```bash +pnpm exec playwright test $TEST_PATHS +# For example: pnpm exec playwright test tests/providers/ tests/scans/ +``` + +## Playwright Project Mapping + +Playwright discovers tests by scanning the directories passed to it. The `playwright.config.ts` file defines projects with `testMatch` patterns that control which spec files each project claims: + +``` +tests/providers/providers.spec.ts -> "providers" project -> depends on admin.auth.setup +tests/scans/scans.spec.ts -> "scans" project -> depends on admin.auth.setup +tests/sign-in-base/*.spec.ts -> "sign-in-base" -> no auth dependency +tests/auth/*.spec.ts -> "auth" -> no auth dependency +tests/sign-up/sign-up.spec.ts -> "sign-up" -> no auth dependency +tests/invitations/invitations.spec.ts -> "invitations" -> depends on admin.auth.setup +``` + +Auth setup projects (`admin.auth.setup`, `manage-scans.auth.setup`, and others) create authenticated browser state files. Projects that declare them as `dependencies` wait for the setup to complete before running. + +When TIA runs only `tests/providers/`, Playwright still automatically runs `admin.auth.setup` because the `providers` project declares it as a dependency. + +## Edge Cases and Known Considerations + +### Forward-Looking Patterns (Empty Test Directories) + +A module can reference `ui/tests/attack-paths/**` before any tests exist there. The empty directory check (step 5) gracefully skips it instead of failing. + +### Broad Patterns and the Safety Net + +Modules like `ui-shadcn` and `api-views` list every E2E test suite explicitly to avoid using `ui/tests/**`. If a broad pattern does produce bare `tests/`, the safety net expands it to specific subdirectories, excluding `tests/setups/`. + +### Setup Files and Auth Dependencies + +`ui/tests/setups/**` is listed in the `ignored` section and also filtered in the path resolution pipeline. This double protection ensures setup files are never passed as test targets to Playwright. Auth setups run only when declared as project dependencies. + +### Critical Path Triggering Run-All + +Changes to `.github/workflows/**` or `.github/test-impact.yml` trigger `run-all: true`. This means editing any workflow file (even unrelated ones) runs the full test suite. This behavior is intentional — CI infrastructure changes should be validated broadly. + +### Unmatched Files + +Files that do not match any ignored, critical, or module pattern produce no test output. The `has-tests` flag is set to `false` and consumer workflows skip entirely via the `skip-e2e` job. + +## Adding New Test Modules + +To add tests for a new UI feature (for example, `dashboards`): + +1. **Add the module to `.github/test-impact.yml`:** + +```yaml +- name: ui-dashboards + match: + - ui/components/dashboards/** + - ui/actions/dashboards/** + - ui/app/**/dashboards/** + - ui/tests/dashboards/** + tests: [] + e2e: + - ui/tests/dashboards/** +``` + +2. **Create the test directory and spec file:** + +``` +ui/tests/dashboards/dashboards.spec.ts +``` + +3. **Add a Playwright project in `ui/playwright.config.ts`:** + +```typescript +{ + name: "dashboards", + testMatch: "dashboards.spec.ts", + dependencies: ["admin.auth.setup"], // if tests need auth +}, +``` + +4. **Register E2E paths in shared UI modules (if applicable):** + + If the feature uses shared UI components, add the E2E path to the `ui-shadcn` module so that changes to shared components also trigger dashboard tests: + +```yaml +- name: ui-shadcn + match: + - ui/components/shadcn/** + - ui/components/ui/** + e2e: + - ui/tests/dashboards/** # Add here + # ... existing paths +``` + +5. **Register E2E paths in API modules (if applicable):** + + If API changes affect this feature, add the E2E path to the relevant `api-*` module (for example, `api-views`). + +## Troubleshooting + +### Tests Not Running When Expected + +1. Check whether the changed file matches an `ignored` pattern. The script logs `[IGNORED]` to stderr. +2. Verify the file matches a module's `match` pattern. To test locally, run: + ```bash + python .github/scripts/test-impact.py path/to/changed/file.ts + ``` +3. Confirm the module has non-empty `e2e` (for E2E) or `tests` (for unit tests). +4. Check the `has-ui-e2e` output — the consumer workflow gates on this flag. + +### Unexpected Auth Setup Errors + +Auth setup projects run automatically when a test project declares them as `dependencies`. If auth failures occur: + +- **Verify secrets:** Confirm that the `E2E_ADMIN_USER` and `E2E_ADMIN_PASSWORD` secrets are set. +- **Check setup file existence:** Ensure the auth setup file exists in `ui/tests/setups/`. +- **Validate test match patterns:** Ensure the `testMatch` pattern in `playwright.config.ts` correctly matches the setup file. + +### "No Tests Found" Errors + +This typically means the path resolution pipeline produced valid directories but Playwright could not match any spec files to a project: + +- **Check project configuration:** Verify that `playwright.config.ts` has a project with a `testMatch` pattern for the spec files in that directory. +- **Verify file naming:** Confirm the spec file naming matches the expected pattern (for example, `feature.spec.ts`). + +### "No Runnable E2E Test Paths After Filtering Setups" + +All resolved paths were under `tests/setups/`. This indicates the module's `e2e` patterns only point to setup files, which is a configuration error. The module should be updated to point to actual test directories. + +### Debugging Locally + +```bash +# See what the analysis engine produces for specific files +python .github/scripts/test-impact.py ui/components/providers/some-file.tsx + +# Output goes to stderr (analysis log) and GITHUB_OUTPUT (structured output) +# Without the GITHUB_OUTPUT env var, results print to stderr only +``` diff --git a/docs/docs.json b/docs/docs.json index 6c2fff3ad2..2e39c4beac 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -131,11 +131,13 @@ "user-guide/tutorials/prowler-app-lighthouse-multi-llm" ] }, + "user-guide/tutorials/prowler-app-attack-paths", "user-guide/tutorials/prowler-cloud-public-ips", { "group": "Tutorials", "pages": [ "user-guide/tutorials/prowler-app-sso-entra", + "user-guide/tutorials/prowler-app-sso-google-workspace", "user-guide/tutorials/bulk-provider-provisioning", "user-guide/tutorials/aws-organizations-bulk-provisioning" ] @@ -273,7 +275,8 @@ { "group": "Image", "pages": [ - "user-guide/providers/image/getting-started-image" + "user-guide/providers/image/getting-started-image", + "user-guide/providers/image/authentication" ] }, { @@ -295,6 +298,13 @@ "user-guide/providers/openstack/getting-started-openstack", "user-guide/providers/openstack/authentication" ] + }, + { + "group": "Vercel", + "pages": [ + "user-guide/providers/vercel/getting-started-vercel", + "user-guide/providers/vercel/authentication" + ] } ] }, @@ -303,6 +313,13 @@ "pages": [ "user-guide/compliance/tutorials/threatscore" ] + }, + { + "group": "Cookbooks", + "pages": [ + "user-guide/cookbooks/kubernetes-in-cluster", + "user-guide/cookbooks/cicd-pipeline" + ] } ] }, diff --git a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx index bf0092a069..d80d8e511d 100644 --- a/docs/getting-started/basic-usage/prowler-mcp-tools.mdx +++ b/docs/getting-started/basic-usage/prowler-mcp-tools.mdx @@ -10,7 +10,7 @@ Complete reference guide for all tools available in the Prowler MCP Server. Tool |----------|------------|------------------------| | Prowler Hub | 10 tools | No | | Prowler Documentation | 2 tools | No | -| Prowler Cloud/App | 24 tools | Yes | +| Prowler Cloud/App | 29 tools | Yes | ## Tool Naming Convention @@ -60,6 +60,7 @@ Tools for searching, viewing, and analyzing cloud resources discovered by Prowle - **`prowler_app_list_resources`** - List and filter cloud resources with advanced filtering options (provider, region, service, resource type, tags) - **`prowler_app_get_resource`** - Get comprehensive details about a specific resource including configuration, metadata, and finding relationships +- **`prowler_app_get_resource_events`** - Get the timeline of cloud API actions performed on a resource (AWS CloudTrail). Shows who did what and when, with full request/response payloads - **`prowler_app_get_resources_overview`** - Get aggregate statistics about cloud resources as a markdown report ### Muting Management @@ -80,6 +81,15 @@ Tools for managing finding muting, including pattern-based bulk muting (mutelist - **`prowler_app_update_mute_rule`** - Update a mute rule's name, reason, or enabled status - **`prowler_app_delete_mute_rule`** - Delete a mute rule from the system +### Attack Paths Analysis + +Tools for analyzing privilege escalation chains and security misconfigurations using graph-based analysis. Attack Paths maps relationships between cloud resources, permissions, and security findings to detect how privileges can be escalated and how misconfigurations can be exploited. + +- **`prowler_app_list_attack_paths_scans`** - List Attack Paths scans with filtering by provider, provider type, and scan state (available, scheduled, executing, completed, failed, cancelled) +- **`prowler_app_list_attack_paths_queries`** - Discover available Attack Paths queries for a completed scan, including query names, descriptions, and required parameters +- **`prowler_app_run_attack_paths_query`** - Execute an Attack Paths query against a completed scan and retrieve graph results with nodes (cloud resources, findings, virtual nodes) and relationships (access paths, role assumptions, security group memberships) +- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema (node labels, relationships, properties) for writing accurate custom openCypher queries + ### Compliance Management Tools for viewing compliance status and framework details across all cloud providers. diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index a4eb883438..681034885c 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -121,8 +121,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.18.0" -PROWLER_API_VERSION="5.18.0" +PROWLER_UI_VERSION="5.22.0" +PROWLER_API_VERSION="5.22.0" ``` diff --git a/docs/getting-started/installation/prowler-cli.mdx b/docs/getting-started/installation/prowler-cli.mdx index 8892aaa1d2..2f82d53166 100644 --- a/docs/getting-started/installation/prowler-cli.mdx +++ b/docs/getting-started/installation/prowler-cli.mdx @@ -4,7 +4,7 @@ title: 'Installation' ## Installation -To install Prowler as a Python package, use `Python >= 3.9, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): +To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): @@ -12,7 +12,7 @@ To install Prowler as a Python package, use `Python >= 3.9, <= 3.12`. Prowler is _Requirements_: - * `Python >= 3.9, <= 3.12` + * `Python >= 3.10, <= 3.12` * `pipx` installed: [pipx installation](https://pipx.pypa.io/stable/installation/). * AWS, GCP, Azure and/or Kubernetes credentials @@ -30,7 +30,7 @@ To install Prowler as a Python package, use `Python >= 3.9, <= 3.12`. Prowler is _Requirements_: - * `Python >= 3.9, <= 3.12` + * `Python >= 3.10, <= 3.12` * `Python pip >= 21.0.0` * AWS, GCP, Azure, M365 and/or Kubernetes credentials @@ -87,7 +87,7 @@ To install Prowler as a Python package, use `Python >= 3.9, <= 3.12`. Prowler is _Requirements_: - * `Python >= 3.9, <= 3.12` + * `Python >= 3.10, <= 3.12` * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: @@ -102,8 +102,8 @@ To install Prowler as a Python package, use `Python >= 3.9, <= 3.12`. Prowler is _Requirements_: - * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.9, <= 3.12` is installed. - * `Python >= 3.9, <= 3.12` + * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.12` is installed. + * `Python >= 3.10, <= 3.12` * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: diff --git a/docs/getting-started/products/prowler-mcp.mdx b/docs/getting-started/products/prowler-mcp.mdx index df402f605b..ea6fe8fcc0 100644 --- a/docs/getting-started/products/prowler-mcp.mdx +++ b/docs/getting-started/products/prowler-mcp.mdx @@ -24,6 +24,7 @@ Full access to Prowler Cloud platform and self-managed Prowler App for: - **Scan Orchestration**: Trigger on-demand scans and schedule recurring security assessments - **Resource Inventory**: Search and view detailed information about your audited resources - **Muting Management**: Create and manage muting lists/rules to suppress non-relevant findings +- **Attack Paths Analysis**: Analyze privilege escalation chains and security misconfigurations through graph-based analysis of cloud resource relationships ### 2. Prowler Hub @@ -61,6 +62,7 @@ The Prowler MCP Server enables powerful workflows through AI assistants: - "Show me all critical findings from my AWS production accounts" - "Register my new AWS account in Prowler and run a scheduled scan every day" - "List all muted findings and detect what findgings are muted by a not enough good reason in relation to their severity" +- "Run an attack paths query to find EC2 instances exposed to the Internet with access to sensitive S3 buckets" **Security Research** - "Explain what the S3 bucket public access Prowler check does" diff --git a/docs/images/organizations/onboarding-flow.svg b/docs/images/organizations/onboarding-flow.svg index 15036e9765..f6e11fc0a3 100644 --- a/docs/images/organizations/onboarding-flow.svg +++ b/docs/images/organizations/onboarding-flow.svg @@ -14,8 +14,8 @@ 1 Create Management Account Role - - Manually in IAM + + Quick Create or Manual Allows Prowler to discover your org structure diff --git a/docs/images/organizations/two-roles-architecture.svg b/docs/images/organizations/two-roles-architecture.svg index 4005466a63..c67588b049 100644 --- a/docs/images/organizations/two-roles-architecture.svg +++ b/docs/images/organizations/two-roles-architecture.svg @@ -46,8 +46,8 @@ organizations:ListTagsForResource - - Deploy: MANUALLY in IAM Console + + Deploy: Quick Create link or Manual diff --git a/docs/images/providers/cloudflare-account-id-form.png b/docs/images/providers/cloudflare-account-id-form.png new file mode 100644 index 0000000000..4175c44713 Binary files /dev/null and b/docs/images/providers/cloudflare-account-id-form.png differ diff --git a/docs/images/providers/cloudflare-account-id.png b/docs/images/providers/cloudflare-account-id.png new file mode 100644 index 0000000000..901d1e2659 Binary files /dev/null and b/docs/images/providers/cloudflare-account-id.png differ diff --git a/docs/images/providers/cloudflare-api-email-form.png b/docs/images/providers/cloudflare-api-email-form.png new file mode 100644 index 0000000000..7f9526e93c Binary files /dev/null and b/docs/images/providers/cloudflare-api-email-form.png differ diff --git a/docs/images/providers/cloudflare-auth-selection.png b/docs/images/providers/cloudflare-auth-selection.png new file mode 100644 index 0000000000..4a8610f050 Binary files /dev/null and b/docs/images/providers/cloudflare-auth-selection.png differ diff --git a/docs/images/providers/cloudflare-launch-scan.png b/docs/images/providers/cloudflare-launch-scan.png new file mode 100644 index 0000000000..65b4acd99d Binary files /dev/null and b/docs/images/providers/cloudflare-launch-scan.png differ diff --git a/docs/images/providers/cloudflare-token-form.png b/docs/images/providers/cloudflare-token-form.png new file mode 100644 index 0000000000..a147bcbfe2 Binary files /dev/null and b/docs/images/providers/cloudflare-token-form.png differ diff --git a/docs/images/providers/cloudflare-token-permissions.png b/docs/images/providers/cloudflare-token-permissions.png new file mode 100644 index 0000000000..49926f0415 Binary files /dev/null and b/docs/images/providers/cloudflare-token-permissions.png differ diff --git a/docs/images/providers/cloudflare-token-save.png b/docs/images/providers/cloudflare-token-save.png new file mode 100644 index 0000000000..641c46afb0 Binary files /dev/null and b/docs/images/providers/cloudflare-token-save.png differ diff --git a/docs/images/providers/googleworkspace-check-connection.png b/docs/images/providers/googleworkspace-check-connection.png new file mode 100644 index 0000000000..149f6b33a5 Binary files /dev/null and b/docs/images/providers/googleworkspace-check-connection.png differ diff --git a/docs/images/providers/googleworkspace-credentials-form.png b/docs/images/providers/googleworkspace-credentials-form.png new file mode 100644 index 0000000000..bc85df96b6 Binary files /dev/null and b/docs/images/providers/googleworkspace-credentials-form.png differ diff --git a/docs/images/providers/googleworkspace-customer-id-form.png b/docs/images/providers/googleworkspace-customer-id-form.png new file mode 100644 index 0000000000..ced135e5eb Binary files /dev/null and b/docs/images/providers/googleworkspace-customer-id-form.png differ diff --git a/docs/images/providers/googleworkspace-customer-id.png b/docs/images/providers/googleworkspace-customer-id.png new file mode 100644 index 0000000000..48342fa456 Binary files /dev/null and b/docs/images/providers/googleworkspace-customer-id.png differ diff --git a/docs/images/providers/googleworkspace-launch-scan.png b/docs/images/providers/googleworkspace-launch-scan.png new file mode 100644 index 0000000000..62e9054a23 Binary files /dev/null and b/docs/images/providers/googleworkspace-launch-scan.png differ diff --git a/docs/images/providers/select-cloudflare-prowler-cloud.png b/docs/images/providers/select-cloudflare-prowler-cloud.png new file mode 100644 index 0000000000..508f17d595 Binary files /dev/null and b/docs/images/providers/select-cloudflare-prowler-cloud.png differ diff --git a/docs/images/providers/select-googleworkspace-prowler-cloud.png b/docs/images/providers/select-googleworkspace-prowler-cloud.png new file mode 100644 index 0000000000..b8a83b6e83 Binary files /dev/null and b/docs/images/providers/select-googleworkspace-prowler-cloud.png differ diff --git a/docs/images/prowler-app/attack-paths/execute-query.png b/docs/images/prowler-app/attack-paths/execute-query.png new file mode 100644 index 0000000000..3872f0de2f Binary files /dev/null and b/docs/images/prowler-app/attack-paths/execute-query.png differ diff --git a/docs/images/prowler-app/attack-paths/fullscreen-mode.png b/docs/images/prowler-app/attack-paths/fullscreen-mode.png new file mode 100644 index 0000000000..c5156925ba Binary files /dev/null and b/docs/images/prowler-app/attack-paths/fullscreen-mode.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-filtered.png b/docs/images/prowler-app/attack-paths/graph-filtered.png new file mode 100644 index 0000000000..3ff4822a26 Binary files /dev/null and b/docs/images/prowler-app/attack-paths/graph-filtered.png differ diff --git a/docs/images/prowler-app/attack-paths/graph-visualization.png b/docs/images/prowler-app/attack-paths/graph-visualization.png new file mode 100644 index 0000000000..2ea160a6b2 Binary files /dev/null and b/docs/images/prowler-app/attack-paths/graph-visualization.png differ diff --git a/docs/images/prowler-app/attack-paths/navigation.png b/docs/images/prowler-app/attack-paths/navigation.png new file mode 100644 index 0000000000..d133c7f6b4 Binary files /dev/null and b/docs/images/prowler-app/attack-paths/navigation.png differ diff --git a/docs/images/prowler-app/attack-paths/node-details.png b/docs/images/prowler-app/attack-paths/node-details.png new file mode 100644 index 0000000000..9343eedd7d Binary files /dev/null and b/docs/images/prowler-app/attack-paths/node-details.png differ diff --git a/docs/images/prowler-app/attack-paths/query-parameters.png b/docs/images/prowler-app/attack-paths/query-parameters.png new file mode 100644 index 0000000000..9f44f81838 Binary files /dev/null and b/docs/images/prowler-app/attack-paths/query-parameters.png differ diff --git a/docs/images/prowler-app/attack-paths/query-selector.png b/docs/images/prowler-app/attack-paths/query-selector.png new file mode 100644 index 0000000000..d8b7414156 Binary files /dev/null and b/docs/images/prowler-app/attack-paths/query-selector.png differ diff --git a/docs/images/prowler-app/attack-paths/scan-list-table.png b/docs/images/prowler-app/attack-paths/scan-list-table.png new file mode 100644 index 0000000000..092b539dd0 Binary files /dev/null and b/docs/images/prowler-app/attack-paths/scan-list-table.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-1.png b/docs/images/prowler-app/saml/saml-sso-gw-1.png new file mode 100644 index 0000000000..cebdbd5240 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-1.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-10.png b/docs/images/prowler-app/saml/saml-sso-gw-10.png new file mode 100644 index 0000000000..e60274dd44 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-10.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-13.png b/docs/images/prowler-app/saml/saml-sso-gw-13.png new file mode 100644 index 0000000000..03d8e85f44 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-13.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-15.png b/docs/images/prowler-app/saml/saml-sso-gw-15.png new file mode 100644 index 0000000000..26d948dbfd Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-15.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-17.png b/docs/images/prowler-app/saml/saml-sso-gw-17.png new file mode 100644 index 0000000000..7a0069d61a Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-17.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-19.png b/docs/images/prowler-app/saml/saml-sso-gw-19.png new file mode 100644 index 0000000000..9457336148 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-19.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-2.png b/docs/images/prowler-app/saml/saml-sso-gw-2.png new file mode 100644 index 0000000000..e3b539fc2e Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-2.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-3.png b/docs/images/prowler-app/saml/saml-sso-gw-3.png new file mode 100644 index 0000000000..8c163f6ff4 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-3.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-4.png b/docs/images/prowler-app/saml/saml-sso-gw-4.png new file mode 100644 index 0000000000..df96b3912b Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-4.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-5.png b/docs/images/prowler-app/saml/saml-sso-gw-5.png new file mode 100644 index 0000000000..88a2984cee Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-5.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-7.png b/docs/images/prowler-app/saml/saml-sso-gw-7.png new file mode 100644 index 0000000000..a2050b6ff2 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-7.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-8.png b/docs/images/prowler-app/saml/saml-sso-gw-8.png new file mode 100644 index 0000000000..4f5d68a020 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-8.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-1.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-1.png new file mode 100644 index 0000000000..ca077ca5aa Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-1.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-2.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-2.png new file mode 100644 index 0000000000..f0e3b597bb Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-2.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-3.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-3.png new file mode 100644 index 0000000000..bf186a1e21 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-3.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-4.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-4.png new file mode 100644 index 0000000000..dd558d70cb Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-4.png differ diff --git a/docs/images/prowler-app/saml/saml-sso-gw-prowler-5.png b/docs/images/prowler-app/saml/saml-sso-gw-prowler-5.png new file mode 100644 index 0000000000..47ee49f6a0 Binary files /dev/null and b/docs/images/prowler-app/saml/saml-sso-gw-prowler-5.png differ diff --git a/docs/introduction.mdx b/docs/introduction.mdx index c354f2802d..766f60e3c0 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -33,12 +33,14 @@ The supported providers right now are: | [Github](/user-guide/providers/github/getting-started-github) | Official | Organizations / Repositories | UI, API, CLI | | [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | | [Alibaba Cloud](/user-guide/providers/alibabacloud/getting-started-alibabacloud) | Official | Accounts | UI, API, CLI | -| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | CLI | +| [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI | | [Infra as Code](/user-guide/providers/iac/getting-started-iac) | Official | Repositories | UI, API, CLI | | [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI | -| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | CLI | +| [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI | +| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | CLI | | [LLM](/user-guide/providers/llm/getting-started-llm) | Official | Models | CLI | -| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images | CLI | +| [Image](/user-guide/providers/image/getting-started-image) | Official | Container Images | CLI, API | +| [Google Workspace](/user-guide/providers/googleworkspace/getting-started-googleworkspace) | Official | Domains | CLI | | **NHN** | Unofficial | Tenants | CLI | For more information about the checks and compliance of each provider visit [Prowler Hub](https://hub.prowler.com). diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index e5039bff21..7f96891cbb 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -73,6 +73,7 @@ The following list includes all the AWS checks with configurable variables that | `ssm_documents_set_as_public` | `trusted_account_ids` | List of Strings | | `vpc_endpoint_connections_trust_boundaries` | `trusted_account_ids` | List of Strings | | `vpc_endpoint_services_allowed_principals_trust_boundaries` | `trusted_account_ids` | List of Strings | +| `opensearch_service_domains_not_publicly_accessible` | `trusted_ips` | List of Strings | ## Azure @@ -140,6 +141,22 @@ The following list includes all the GitHub checks with configurable variables th |--------------------------------------------|---------------------------------------------|---------| | `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer | +## Vercel + +### Configurable Checks +The following list includes all the Vercel checks with configurable variables that can be changed in the configuration YAML file: + +| Check Name | Value | Type | +|-----------------------------------------------------|------------------------------------|-----------------| +| `authentication_no_stale_tokens` | `stale_token_threshold_days` | Integer | +| `authentication_token_not_expired` | `days_to_expire_threshold` | Integer | +| `deployment_production_uses_stable_target` | `stable_branches` | List of Strings | +| `domain_ssl_certificate_valid` | `days_to_expire_threshold` | Integer | +| `project_environment_no_secrets_in_plain_type` | `secret_suffixes` | List of Strings | +| `team_member_role_least_privilege` | `max_owner_percentage` | Integer | +| `team_member_role_least_privilege` | `max_owners` | Integer | +| `team_no_stale_invitations` | `stale_invitation_threshold_days` | Integer | + ## Config YAML File Structure @@ -623,5 +640,29 @@ github: # github.repository_inactive_not_archived inactive_not_archived_days_threshold: 180 +# Vercel Configuration +vercel: + # vercel.deployment_production_uses_stable_target + stable_branches: + - "main" + - "master" + # vercel.authentication_token_not_expired & vercel.domain_ssl_certificate_valid + days_to_expire_threshold: 7 + # vercel.authentication_no_stale_tokens + stale_token_threshold_days: 90 + # vercel.team_no_stale_invitations + stale_invitation_threshold_days: 30 + # vercel.team_member_role_least_privilege + max_owner_percentage: 20 + max_owners: 3 + # vercel.project_environment_no_secrets_in_plain_type + secret_suffixes: + - "_KEY" + - "_SECRET" + - "_TOKEN" + - "_PASSWORD" + - "_API_KEY" + - "_PRIVATE_KEY" + ``` diff --git a/docs/user-guide/cli/tutorials/dashboard.mdx b/docs/user-guide/cli/tutorials/dashboard.mdx index 8c819743fe..0fe3c69fed 100644 --- a/docs/user-guide/cli/tutorials/dashboard.mdx +++ b/docs/user-guide/cli/tutorials/dashboard.mdx @@ -99,7 +99,7 @@ def get_table(data): ## S3 Integration -If you are using Prowler SaaS with the S3 integration or that integration from Prowler Open Source and you want to use your data from your S3 bucket, you can run the following command in order to load the dashboard with the new files: +If you are using Prowler Cloud with the S3 integration or that integration from Prowler CLI and you want to use your data from your S3 bucket, you can run the following command in order to load the dashboard with the new files: ```sh aws s3 cp s3:///output/csv ./output --recursive diff --git a/docs/user-guide/cookbooks/cicd-pipeline.mdx b/docs/user-guide/cookbooks/cicd-pipeline.mdx new file mode 100644 index 0000000000..9dffd5049c --- /dev/null +++ b/docs/user-guide/cookbooks/cicd-pipeline.mdx @@ -0,0 +1,243 @@ +--- +title: 'Run Prowler in CI/CD and Send Findings to Prowler Cloud' +--- + +This cookbook demonstrates how to integrate Prowler into CI/CD pipelines so that security scans run automatically and findings are sent to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). Examples cover GitHub Actions and GitLab CI. + +## Prerequisites + +* A **Prowler Cloud** account with an active subscription (see [Prowler Cloud Pricing](https://prowler.com/pricing)) +* A Prowler Cloud **API key** with the **Manage Ingestions** permission (see [API Keys](/user-guide/tutorials/prowler-app-api-keys)) +* Cloud provider credentials configured in the CI/CD environment (e.g., AWS credentials for scanning AWS accounts) +* Access to configure pipeline workflows and secrets in the CI/CD platform + +## Key Concepts + +Prowler CLI provides the `--push-to-cloud` flag, which uploads scan results directly to Prowler Cloud after a scan completes. Combined with the `PROWLER_CLOUD_API_KEY` environment variable, this enables fully automated ingestion without manual file uploads. + +For full details on the flag and API, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation. + + +The examples in this guide use AWS as the target provider, but the same approach applies to any provider supported by Prowler (Azure, GCP, Kubernetes, and others). Replace `prowler aws` with the desired provider command (e.g., `prowler gcp`, `prowler azure`) and configure the corresponding credentials in the CI/CD environment. + + +## GitHub Actions + +### Store Secrets + +Before creating the workflow, add the following secrets to the repository (under "Settings" > "Secrets and variables" > "Actions"): + +* `PROWLER_CLOUD_API_KEY` — the Prowler Cloud API key +* Cloud provider credentials (e.g., `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, or configure OIDC-based role assumption) + +### Workflow: Scheduled AWS Scan + +This workflow runs Prowler against an AWS account on a daily schedule and on every push to the `main` branch: + +```yaml +name: Prowler Security Scan + +on: + schedule: + - cron: "0 3 * * *" # Daily at 03:00 UTC + push: + branches: [main] + workflow_dispatch: # Allow manual triggers + +permissions: + id-token: write # Required for OIDC + contents: read + +jobs: + prowler-scan: + runs-on: ubuntu-latest + steps: + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/ProwlerScanRole + aws-region: us-east-1 + + - name: Install Prowler + run: pip install prowler + + - name: Run Prowler Scan + env: + PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }} + run: | + prowler aws --push-to-cloud +``` + + +Replace `123456789012` with the actual AWS account ID and `ProwlerScanRole` with the IAM role name. For IAM role setup, refer to the [AWS authentication guide](/user-guide/providers/aws/authentication). + + +### Workflow: Scan Specific Services on Pull Request + +To run targeted scans on pull requests without blocking the merge pipeline, use `continue-on-error`: + +```yaml +name: Prowler PR Check + +on: + pull_request: + branches: [main] + +jobs: + prowler-scan: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/ProwlerScanRole + aws-region: us-east-1 + + - name: Install Prowler + run: pip install prowler + + - name: Run Prowler Scan + env: + PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }} + run: | + prowler aws --services s3,iam,ec2 --push-to-cloud +``` + + +Limiting the scan to specific services with `--services` reduces execution time, making it practical for pull request checks. + + +## GitLab CI + +### Store Variables + +Add the following CI/CD variables in the GitLab project (under "Settings" > "CI/CD" > "Variables"): + +* `PROWLER_CLOUD_API_KEY` — mark as **masked** and **protected** +* Cloud provider credentials as needed + +### Pipeline: Scheduled AWS Scan + +Add the following to `.gitlab-ci.yml`: + +```yaml +prowler-scan: + image: python:3.12-slim + stage: test + script: + - pip install prowler + - prowler aws --push-to-cloud + variables: + PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY + AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION: "us-east-1" + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual +``` + +To run the scan on a schedule, create a **Pipeline Schedule** in GitLab (under "Build" > "Pipeline Schedules") with the desired cron expression. + +### Pipeline: Multi-Provider Scan + +To scan multiple cloud providers in parallel: + +```yaml +stages: + - security + +.prowler-base: + image: python:3.12-slim + stage: security + before_script: + - pip install prowler + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + +prowler-aws: + extends: .prowler-base + script: + - prowler aws --push-to-cloud + variables: + PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY + AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY + +prowler-gcp: + extends: .prowler-base + script: + - prowler gcp --push-to-cloud + variables: + PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY + GOOGLE_APPLICATION_CREDENTIALS: $GCP_SERVICE_ACCOUNT_KEY +``` + +## Tips and Best Practices + +### When to Run Scans + +* **Scheduled scans** (daily or weekly) provide continuous monitoring and are ideal for baseline security assessments +* **On-merge scans** catch configuration changes introduced by new code +* **Pull request scans** provide early feedback but should target specific services to keep execution times reasonable + +### Handling Scan Failures + +By default, Prowler exits with a non-zero code when it finds failing checks. This causes the CI/CD job to fail. To prevent scan results from blocking the pipeline: + +* **GitHub Actions**: Add `continue-on-error: true` to the job +* **GitLab CI**: Add `allow_failure: true` to the job + + +Ingestion failures (e.g., network issues reaching Prowler Cloud) do not affect the Prowler exit code. The scan completes normally and only a warning is emitted. See [Import Findings troubleshooting](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for details. + + +### Caching Prowler Installation + +For faster pipeline runs, cache the Prowler installation: + +**GitHub Actions:** +```yaml +- name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-prowler + restore-keys: ${{ runner.os }}-pip- + +- name: Install Prowler + run: pip install prowler +``` + +**GitLab CI:** +```yaml +prowler-scan: + cache: + paths: + - .cache/pip + variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" +``` + +### Output Formats + +To generate additional report formats alongside the cloud upload: + +```bash +prowler aws --push-to-cloud -M csv,html -o /tmp/prowler-reports +``` + +This produces CSV and HTML files locally while also pushing OCSF findings to Prowler Cloud. The local files can be stored as CI/CD artifacts for archival purposes. + +### Scanning Multiple AWS Accounts + +To scan multiple accounts sequentially in a single job, use [role assumption](/user-guide/providers/aws/role-assumption): + +```bash +prowler aws -R arn:aws:iam::111111111111:role/ProwlerScanRole --push-to-cloud +prowler aws -R arn:aws:iam::222222222222:role/ProwlerScanRole --push-to-cloud +``` + +Each scan run creates a separate ingestion job in Prowler Cloud. diff --git a/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx new file mode 100644 index 0000000000..765bcf9313 --- /dev/null +++ b/docs/user-guide/cookbooks/kubernetes-in-cluster.mdx @@ -0,0 +1,207 @@ +--- +title: 'Run Kubernetes In-Cluster and Send Findings to Prowler Cloud' +--- + +This cookbook walks through deploying Prowler inside a Kubernetes cluster on a recurring schedule and automatically sending findings to Prowler Cloud via [Import Findings](/user-guide/tutorials/prowler-app-import-findings). By the end, security scan results from the cluster appear in Prowler Cloud without any manual file uploads. + +## Prerequisites + +* A **Prowler Cloud** account with an active subscription (see [Prowler Cloud Pricing](https://prowler.com/pricing)) +* A Prowler Cloud **API key** with the **Manage Ingestions** permission (see [API Keys](/user-guide/tutorials/prowler-app-api-keys)) +* Access to a Kubernetes cluster with `kubectl` configured +* Permissions to create ServiceAccounts, Roles, RoleBindings, Secrets, and CronJobs in the cluster + +## Step 1: Create the ServiceAccount and RBAC Resources + +Prowler needs a ServiceAccount with read access to cluster resources. Apply the manifests from the [`kubernetes` directory](https://github.com/prowler-cloud/prowler/tree/master/kubernetes) of the Prowler repository: + +```console +kubectl apply -f kubernetes/prowler-sa.yaml +kubectl apply -f kubernetes/prowler-role.yaml +kubectl apply -f kubernetes/prowler-rolebinding.yaml +``` + +This creates: + +* A `prowler-sa` ServiceAccount in the `prowler-ns` namespace +* A ClusterRole with the read permissions Prowler requires +* A ClusterRoleBinding linking the ServiceAccount to the role + +For more details on these resources, refer to [Getting Started with Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s). + +## Step 2: Store the Prowler Cloud API Key as a Secret + +Create a Kubernetes Secret to hold the API key securely: + +```console +kubectl create secret generic prowler-cloud-api-key \ + --from-literal=api-key=pk_your_api_key_here \ + --namespace prowler-ns +``` + +Replace `pk_your_api_key_here` with the actual API key from Prowler Cloud. + + +Avoid embedding the API key directly in the CronJob manifest. Using a Kubernetes Secret keeps credentials out of version control and pod specs. + + +## Step 3: Create the CronJob Manifest + +The CronJob runs Prowler on a schedule, scanning the cluster and pushing findings to Prowler Cloud with the `--push-to-cloud` flag. + +Create a file named `prowler-cronjob.yaml`: + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: prowler-k8s-scan + namespace: prowler-ns +spec: + schedule: "0 2 * * *" # Runs daily at 02:00 UTC + concurrencyPolicy: Forbid + jobTemplate: + spec: + backoffLimit: 1 + template: + metadata: + labels: + app: prowler + spec: + serviceAccountName: prowler-sa + containers: + - name: prowler + image: prowlercloud/prowler:stable + args: + - "kubernetes" + - "--push-to-cloud" + env: + - name: PROWLER_CLOUD_API_KEY + valueFrom: + secretKeyRef: + name: prowler-cloud-api-key + key: api-key + - name: CLUSTER_NAME + value: "my-cluster" + imagePullPolicy: Always + volumeMounts: + - name: var-lib-cni + mountPath: /var/lib/cni + readOnly: true + - name: var-lib-etcd + mountPath: /var/lib/etcd + readOnly: true + - name: var-lib-kubelet + mountPath: /var/lib/kubelet + readOnly: true + - name: etc-kubernetes + mountPath: /etc/kubernetes + readOnly: true + hostPID: true + restartPolicy: Never + volumes: + - name: var-lib-cni + hostPath: + path: /var/lib/cni + - name: var-lib-etcd + hostPath: + path: /var/lib/etcd + - name: var-lib-kubelet + hostPath: + path: /var/lib/kubelet + - name: etc-kubernetes + hostPath: + path: /etc/kubernetes +``` + + +Replace `my-cluster` with a meaningful name for the cluster. This value appears in Prowler Cloud reports and helps identify the source of findings. See the `--cluster-name` flag documentation in [Getting Started with Kubernetes](/user-guide/providers/kubernetes/getting-started-k8s) for more details. + + +### Customizing the Schedule + +The `schedule` field uses standard cron syntax. Common examples: + +* `"0 2 * * *"` — daily at 02:00 UTC +* `"0 */6 * * *"` — every 6 hours +* `"0 2 * * 1"` — weekly on Mondays at 02:00 UTC + +### Scanning Specific Namespaces + +To limit the scan to specific namespaces, add the `--namespace` flag to the `args` array: + +```yaml +args: + - "kubernetes" + - "--push-to-cloud" + - "--namespace" + - "production,staging" +``` + +## Step 4: Deploy and Verify + +Apply the CronJob to the cluster: + +```console +kubectl apply -f prowler-cronjob.yaml +``` + +To trigger an immediate test run without waiting for the schedule: + +```console +kubectl create job prowler-test-run --from=cronjob/prowler-k8s-scan -n prowler-ns +``` + +Monitor the job execution: + +```console +kubectl get pods -n prowler-ns -l app=prowler --watch +``` + +Check the logs to confirm findings were pushed successfully: + +```console +kubectl logs -n prowler-ns -l app=prowler --tail=50 +``` + +A successful upload produces output similar to: + +``` +Pushing findings to Prowler Cloud, please wait... + +Findings successfully pushed to Prowler Cloud. Ingestion job: fa8bc8c5-4925-46a0-9fe0-f6575905e094 +See more details here: https://cloud.prowler.com/scans +``` + +## Step 5: View Findings in Prowler Cloud + +Once the job completes and findings are pushed: + +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) +2. Open the "Scans" section to verify the ingestion job status +3. Browse findings under the Kubernetes provider + +For details on the ingestion workflow and status tracking, refer to the [Import Findings](/user-guide/tutorials/prowler-app-import-findings) documentation. + +## Tips and Troubleshooting + +* **Resource limits**: For large clusters, consider setting `resources.requests` and `resources.limits` on the container to prevent the scan from consuming excessive cluster resources. +* **Network policies**: Ensure the Prowler pod can reach `api.prowler.com` over HTTPS (port 443). Adjust NetworkPolicies or egress rules if needed. +* **Job history**: Kubernetes retains completed and failed jobs by default. Set `successfulJobsHistoryLimit` and `failedJobsHistoryLimit` in the CronJob spec to control cleanup: + + ```yaml + spec: + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + ``` + +* **API key rotation**: When rotating the API key, update the Secret and restart any running jobs: + + ```console + kubectl delete secret prowler-cloud-api-key -n prowler-ns + kubectl create secret generic prowler-cloud-api-key \ + --from-literal=api-key=pk_new_api_key_here \ + --namespace prowler-ns + ``` + +* **Failed uploads**: If the push to Prowler Cloud fails, the scan still completes and findings are saved locally in the container. Check the [Import Findings troubleshooting section](/user-guide/tutorials/prowler-app-import-findings#troubleshooting) for common error messages. diff --git a/docs/user-guide/img/add-registry-url.png b/docs/user-guide/img/add-registry-url.png new file mode 100644 index 0000000000..df1319ea15 Binary files /dev/null and b/docs/user-guide/img/add-registry-url.png differ diff --git a/docs/user-guide/img/image-authentication-filters.png b/docs/user-guide/img/image-authentication-filters.png new file mode 100644 index 0000000000..da56306e83 Binary files /dev/null and b/docs/user-guide/img/image-authentication-filters.png differ diff --git a/docs/user-guide/img/image-verify-connection.png b/docs/user-guide/img/image-verify-connection.png new file mode 100644 index 0000000000..26bd900062 Binary files /dev/null and b/docs/user-guide/img/image-verify-connection.png differ diff --git a/docs/user-guide/img/select-container-registry.png b/docs/user-guide/img/select-container-registry.png new file mode 100644 index 0000000000..50fd9c3177 Binary files /dev/null and b/docs/user-guide/img/select-container-registry.png differ diff --git a/docs/user-guide/providers/cloudflare/authentication.mdx b/docs/user-guide/providers/cloudflare/authentication.mdx index 8ee8b518cf..eedcd57a33 100644 --- a/docs/user-guide/providers/cloudflare/authentication.mdx +++ b/docs/user-guide/providers/cloudflare/authentication.mdx @@ -1,119 +1,130 @@ --- -title: 'Cloudflare Authentication in Prowler' +title: "Cloudflare Authentication in Prowler" --- -Prowler for Cloudflare supports the following authentication methods: +import { VersionBadge } from "/snippets/version-badge.mdx" -- [**API Token**](#api-token-recommended) (**Recommended**) -- [**API Key and Email (Legacy)**](#api-key-and-email-legacy) + + +Prowler for Cloudflare supports two authentication methods, both available in Prowler Cloud and Prowler CLI: + +- [**API Token**](#api-token-recommended) (**Recommended**) — Scoped, least-privilege access to specific permissions and zones. +- [**API Key and Email**](#api-key-and-email-legacy) (**Legacy**) — Global access to the entire account using the Global API Key. + + +**Use only one authentication method at a time.** If both API Token and API Key + Email are set, Prowler uses the API Token and logs an error about the conflict. + ## Required Permissions -Prowler requires read-only access to your Cloudflare zones and their settings. The following permissions are needed: +Prowler requires read-only access to Cloudflare zones and their settings. The following permissions must be configured when creating the API Token: -| Permission | Description | -|------------|-------------| -| `Zone:Read` | Read access to zone settings and configurations | -| `Zone Settings:Read` | Read access to zone security settings (SSL/TLS, HSTS, etc.) | -| `DNS:Read` | Read access to DNS records (for DNSSEC checks) | +| Resource | Permission | Access | Description | +|----------|------------|--------|-------------| +| `Account` | `Account Settings` | `Read` | Required to list accounts and verify user identity | +| `Zone` | `Zone` | `Read` | Required to list zones, rulesets, bot management, and SSL settings | +| `Zone` | `Zone Settings` | `Read` | Required to read zone security settings (TLS, HSTS, WAF, etc.) | +| `Zone` | `DNS` | `Read` | Required to read DNS records and DNSSEC status | -Ensure your API Token or API Key has access to all zones you want to scan. If permissions are missing, some checks may fail or return incomplete results. +Ensure the API Token has access to all zones targeted for scanning. Missing permissions may cause some checks to fail or return incomplete results. +--- + ## API Token (Recommended) -API Tokens are the recommended authentication method because they: +User API Tokens are the recommended authentication method because they: + - Can be scoped to specific permissions and zones - Are more secure than global API keys - Can be easily rotated without affecting other integrations -### Step 1: Create an API Token + +Create a **User API Token**, not an Account API Token. User API Tokens are created from the profile settings and offer finer permission control. + -1. **Log into Cloudflare Dashboard** - - Go to [https://dash.cloudflare.com](https://dash.cloudflare.com) and sign in +### Step 1: Create a User API Token -2. **Navigate to API Tokens** - - Click on your profile icon in the top right corner - - Select **My Profile** - - Click on the **API Tokens** tab - -3. **Create a Custom Token** - - Click **Create Token** - - Select **Create Custom Token** (at the bottom) - -4. **Configure Token Permissions** - - Give your token a descriptive name (e.g., "Prowler Security Scanner") and add the [required permissions](#required-permissions) listed above. - -5. **Set Zone Resources** - - Under **Zone Resources**, select either: - - **Include → All zones** (to scan all zones in your account) +1. Log into the [Cloudflare Dashboard](https://dash.cloudflare.com). +2. Click on the profile icon in the top right corner, then select "My Profile". +3. Click on the **API Tokens** tab. +4. Click **Create Token**, then select **Create Custom Token** at the bottom of the page. +5. Configure the token with the following settings: + - **Token name:** A descriptive name (e.g., "Prowler Security Scanner") + - **Permissions:** + - `Account` — `Account Settings` — `Read` + - `Zone` — `Zone` — `Read` + - `Zone` — `Zone Settings` — `Read` + - `Zone` — `DNS` — `Read` + - **Zone Resources:** Select either: + - **Include → All zones** (to scan all zones in the account) - **Include → Specific zone** (to limit access to specific zones) -6. **Create and Copy Token** - - Click **Continue to summary** - - Review the permissions and click **Create Token** - - **Copy the token immediately** - Cloudflare will only show it once + ![Token Permissions](/images/providers/cloudflare-token-permissions.png) -### Step 2: Store the Token Securely +6. Configure the **Account Resources** and **Zone Resources**, and optionally set a **TTL** for the token expiration. Click **Continue to summary**. -Store your API token as an environment variable: + ![Token Resources and TTL](/images/providers/cloudflare-token-save.png) -```bash -export CLOUDFLARE_API_TOKEN="your-api-token-here" -``` +7. Review the permissions and click **Create Token**. +8. Copy the token immediately. -Never commit API tokens to version control or share them in plain text. Use environment variables or a secrets manager. +Cloudflare only displays the token once. Copy it immediately and store it securely. If lost, a new token must be created. +### Step 2: Provide the Token to Prowler + +- **Prowler Cloud:** Paste the token in the credentials form when configuring the Cloudflare provider. +- **Prowler CLI:** Export the token as an environment variable: + +```console +export CLOUDFLARE_API_TOKEN="your-api-token-here" +prowler cloudflare +``` + +--- + ## API Key and Email (Legacy) -API Keys provide full access to your Cloudflare account. While supported, this method is less secure than API Tokens because it grants broader permissions. +API Keys provide full access to the Cloudflare account. While supported, this method is less secure than API Tokens because it grants broader permissions. -### Step 1: Get Your API Key +### Step 1: Get the Global API Key -1. **Log into Cloudflare Dashboard** - - Go to [https://dash.cloudflare.com](https://dash.cloudflare.com) and sign in +1. Log into the [Cloudflare Dashboard](https://dash.cloudflare.com). +2. Click on the profile icon in the top right corner, then select "My Profile". +3. Click on the **API Tokens** tab. +4. Scroll down to the **API Keys** section. +5. Click **View** next to **Global API Key**. +6. Enter the account password to reveal the key, then copy it. -2. **Navigate to API Tokens** - - Click on your profile icon in the top right corner - - Select **My Profile** - - Click on the **API Tokens** tab +### Step 2: Provide the Credentials to Prowler -3. **View Global API Key** - - Scroll down to the **API Keys** section - - Click **View** next to **Global API Key** - - Enter your password to reveal the key - - Copy the API key +- **Prowler Cloud:** Enter the Global API Key and email in the credentials form when configuring the Cloudflare provider. +- **Prowler CLI:** Export both values as environment variables: -### Step 2: Store Credentials Securely - -Store both your API key and email as environment variables: - -```bash +```console export CLOUDFLARE_API_KEY="your-api-key-here" export CLOUDFLARE_API_EMAIL="your-email@example.com" +prowler cloudflare ``` -The email must be the same email address used to log into your Cloudflare account. +The email must match the email address used to log into the Cloudflare account. +--- + ## Best Practices -### Security Recommendations +- **Use API Tokens instead of API Keys** — Tokens can be scoped to specific permissions and zones. +- **Use environment variables** — Never hardcode credentials in scripts or commands. +- **Rotate credentials regularly** — Create new tokens periodically and revoke old ones. +- **Use least privilege** — Only grant the minimum permissions needed for scanning. +- **Monitor token usage** — Review the Cloudflare audit log for suspicious activity. -- **Use API Tokens instead of API Keys** - Tokens can be scoped to specific permissions -- **Use environment variables** - Never hardcode credentials in scripts or commands -- **Rotate credentials regularly** - Create new tokens periodically and revoke old ones -- **Use least privilege** - Only grant the minimum permissions needed -- **Monitor token usage** - Review the Cloudflare audit log for suspicious activity - - -**Use only one authentication method at a time.** If both API Token and API Key + Email are set, Prowler will use the API Token and log an error message. - +--- ## Troubleshooting @@ -123,20 +134,15 @@ This error occurs when using API Key authentication without providing the email ### "Authentication error" or "Permission denied" -- Verify your API Token or API Key is correct and not expired -- Check that your token has the [required permissions](#required-permissions) -- Ensure your token has access to the zones you're trying to scan +- Verify the API Token or API Key is correct and not expired. +- Check that the token has the [required permissions](#required-permissions). +- Ensure the token has access to the zones targeted for scanning. ### "Both API Token and API Key and Email credentials are set" -This warning appears when all three environment variables are set: -- `CLOUDFLARE_API_TOKEN` -- `CLOUDFLARE_API_KEY` -- `CLOUDFLARE_API_EMAIL` +This warning appears when all three environment variables are set (`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_API_KEY`, `CLOUDFLARE_API_EMAIL`). To resolve, unset the credentials that are not needed: -To resolve, unset the credentials you don't want to use: - -```bash +```console # To use API Token only (recommended) unset CLOUDFLARE_API_KEY unset CLOUDFLARE_API_EMAIL @@ -144,3 +150,7 @@ unset CLOUDFLARE_API_EMAIL # Or to use API Key and Email only unset CLOUDFLARE_API_TOKEN ``` + +### "Account not found" Error + +This error occurs when a specified `--account-id` is not accessible with the current credentials. Verify the Account ID is correct and that the credentials have access to the target account. diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx index d3c916750e..2bc18bcca8 100644 --- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -1,117 +1,165 @@ --- -title: 'Getting Started with Cloudflare' +title: 'Getting Started With Cloudflare on Prowler' --- -import { VersionBadge } from "/snippets/version-badge.mdx"; +import { VersionBadge } from "/snippets/version-badge.mdx" - - -Prowler for Cloudflare allows you to scan your Cloudflare zones for security misconfigurations, including SSL/TLS settings, DNSSEC, HSTS, and more. +Prowler for Cloudflare scans zones for security misconfigurations, including SSL/TLS settings, DNSSEC, HSTS, WAF rules, DNS records, and more. ## Prerequisites -Before running Prowler with the Cloudflare provider, ensure you have: +Set up authentication for Cloudflare with the [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication) guide before starting either path: -1. A Cloudflare account with at least one zone -2. One of the following authentication methods configured (see [Authentication](/user-guide/providers/cloudflare/authentication)): - - An **API Token** (recommended) - - An **API Key + Email** (legacy) +- Create a Cloudflare User API Token (recommended) or locate the Global API Key +- Grant the required read-only permissions (`Account Settings:Read`, `Zone:Read`, `Zone Settings:Read`, `DNS:Read`) +- Identify the Cloudflare Account ID to use as the provider identifier -## Quick Start + + + Onboard Cloudflare using Prowler Cloud + + + Onboard Cloudflare using Prowler CLI + + + +## Prowler Cloud + + + +### Step 1: Locate the Account ID + +1. Log into the [Cloudflare Dashboard](https://dash.cloudflare.com). +2. Select any zone in the target account. +3. On the zone overview page, find the **Account ID** in the right sidebar under the "API" section. + + ![Cloudflare Account ID](/images/providers/cloudflare-account-id.png) + + +The Account ID is a 32-character hexadecimal string (e.g., `372e67954025e0ba6aaa6d586b9e0b59`). This value acts as the unique identifier for the Cloudflare account in Prowler Cloud. + + +### Step 2: Open Prowler Cloud + +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +2. Navigate to "Configuration" > "Cloud Providers". + + ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + +3. Click "Add Cloud Provider". + + ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + +4. Select "Cloudflare". + + ![Select Cloudflare](/images/providers/select-cloudflare-prowler-cloud.png) + +5. Add the **Account ID** and an optional alias, then click "Next". + + ![Add Cloudflare Account ID](/images/providers/cloudflare-account-id-form.png) + +### Step 3: Choose and Provide Authentication + +After the Account ID is in place, select the authentication method that matches the Cloudflare setup: + +![Select Authentication Method](/images/providers/cloudflare-auth-selection.png) + +#### User API Token Authentication (Recommended) + +1. Select **API Token**. +2. Enter the **User API Token** created in the Cloudflare Dashboard. + + ![API Token Form](/images/providers/cloudflare-token-form.png) + +Use this method for scoped, least-privilege access. Full setup steps are in the [Authentication guide](/user-guide/providers/cloudflare/authentication#api-token-recommended). + +#### API Key and Email Authentication (Legacy) + +1. Select **API Key + Email**. +2. Enter the **Global API Key**. +3. Enter the **email address** associated with the Cloudflare account. + + ![API Key and Email Form](/images/providers/cloudflare-api-email-form.png) + +For the complete setup workflow, follow the [Authentication guide](/user-guide/providers/cloudflare/authentication#api-key-and-email-legacy). + +### Step 4: Launch the Scan + +1. Review the summary. +2. Click **Launch Scan** to start auditing Cloudflare. + + ![Launch Scan](/images/providers/cloudflare-launch-scan.png) + +--- + +## Prowler CLI + + ### Step 1: Set Up Authentication -The recommended method is using an API Token via environment variable: +Choose the matching method from the [Cloudflare Authentication](/user-guide/providers/cloudflare/authentication) guide: -```bash -export CLOUDFLARE_API_TOKEN="your-api-token-here" -``` +- **User API Token** (recommended): Set `CLOUDFLARE_API_TOKEN` +- **API Key + Email** (legacy): Set `CLOUDFLARE_API_KEY` and `CLOUDFLARE_API_EMAIL` -Alternatively, use API Key + Email: +### Step 2: Run the First Scan -```bash -export CLOUDFLARE_API_KEY="your-api-key-here" -export CLOUDFLARE_API_EMAIL="your-email@example.com" -``` +Run a baseline scan after credentials are configured: -### Step 2: Run Prowler - -Run a scan across all your Cloudflare zones: - -```bash +```console prowler cloudflare ``` -That's it! Prowler will automatically discover all zones in your account and run security checks against them. +Prowler automatically discovers all zones accessible with the provided credentials and runs security checks against them. -## Authentication +### Step 3: Filter the Scan Scope (Optional) -Prowler reads Cloudflare credentials from environment variables. Set your credentials before running Prowler: - -**API Token (Recommended):** -```bash -export CLOUDFLARE_API_TOKEN="your-api-token-here" -prowler cloudflare -``` - -**API Key + Email (Legacy):** -```bash -export CLOUDFLARE_API_KEY="your-api-key-here" -export CLOUDFLARE_API_EMAIL="your-email@example.com" -prowler cloudflare -``` - -## Filtering Zones - -By default, Prowler scans all zones accessible with your credentials: - -```bash -prowler cloudflare -``` +#### Filter by Zone To scan only specific zones, use the `-f`, `--region`, or `--filter-region` argument: -```bash +```console prowler cloudflare -f example.com ``` -You can specify multiple zones: +Multiple zones can be specified: -```bash +```console prowler cloudflare -f example.com example.org ``` -You can also use zone IDs instead of domain names: +Zone IDs are also supported: -```bash +```console prowler cloudflare -f 023e105f4ecef8ad9ca31a8372d0c353 ``` -## Filtering Accounts +#### Filter by Account -By default, Prowler scans all accounts accessible with your credentials. If your API Token or API Key has access to multiple Cloudflare accounts, you can restrict the scan to specific accounts using the `--account-id` argument: +To restrict the scan to specific accounts, use the `--account-id` argument: -```bash +```console prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 ``` -You can specify multiple account IDs: +Multiple account IDs can be specified: -```bash +```console prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 9a7806061c88ada191ed06f989cc3dac ``` -If any of the provided account IDs are not found among the accounts accessible with your credentials, Prowler will raise an error and stop execution. +If any of the provided account IDs are not accessible with the current credentials, Prowler raises an error and stops execution. -You can combine account and zone filtering to narrow the scan scope further: +Account and zone filtering can be combined to narrow the scan scope further: -```bash +```console prowler cloudflare --account-id 372e67954025e0ba6aaa6d586b9e0b59 -f example.com ``` -## Configuration +### Step 4: Use a Custom Configuration (Optional) Prowler uses a configuration file to customize provider behavior. The Cloudflare configuration includes: @@ -123,10 +171,8 @@ cloudflare: To use a custom configuration: -```bash +```console prowler cloudflare --config-file /path/to/config.yaml ``` -## Next Steps - -- [Authentication](/user-guide/providers/cloudflare/authentication) - Detailed guide on creating API tokens and keys +--- diff --git a/docs/user-guide/providers/googleworkspace/authentication.mdx b/docs/user-guide/providers/googleworkspace/authentication.mdx index 84ef5c6086..d8812fa7b3 100644 --- a/docs/user-guide/providers/googleworkspace/authentication.mdx +++ b/docs/user-guide/providers/googleworkspace/authentication.mdx @@ -2,9 +2,13 @@ title: 'Google Workspace Authentication in Prowler' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + + + Prowler for Google Workspace uses a **Service Account with Domain-Wide Delegation** to authenticate to the Google Workspace Admin SDK. This allows Prowler to read directory data on behalf of a super administrator without requiring an interactive login. -## Required OAuth Scopes +## Required Open Authorization (OAuth) Scopes Prowler requests the following read-only OAuth 2.0 scopes from the Google Workspace Admin SDK: @@ -20,16 +24,16 @@ The delegated user must be a **super administrator** in your Google Workspace or ## Setup Steps -### Step 1: Create a GCP Project (if needed) +### Step 1: Create a Google Cloud Platform (GCP) Project (if Needed) -If you don't have a GCP project, create one at [https://console.cloud.google.com](https://console.cloud.google.com). +If no GCP project exists, create one at [https://console.cloud.google.com](https://console.cloud.google.com). The project is only used to host the Service Account — it does not need to have any Google Workspace data in it. ### Step 2: Enable the Admin SDK API -1. Go to the [Google Cloud Console](https://console.cloud.google.com) -2. Select your project +1. Navigate to the [Google Cloud Console](https://console.cloud.google.com) +2. Select the target project 3. Navigate to **APIs & Services → Library** 4. Search for **Admin SDK API** 5. Click **Enable** @@ -48,8 +52,8 @@ The Service Account does not need any GCP IAM roles. Its access to Google Worksp ### Step 4: Generate a JSON Key -1. Click on the Service Account you just created -2. Go to the **Keys** tab +1. Click the newly created Service Account +2. Navigate to the **Keys** tab 3. Click **Add Key → Create new key** 4. Select **JSON** format 5. Click **Create** — the key file will download automatically @@ -61,7 +65,7 @@ This JSON key grants access to your Google Workspace organization. Never commit ### Step 5: Configure Domain-Wide Delegation in Google Workspace -1. Go to the [Google Workspace Admin Console](https://admin.google.com) +1. Navigate to the [Google Workspace Admin Console](https://admin.google.com) 2. Navigate to **Security → Access and data control → API controls** 3. Click **Manage Domain Wide Delegation** 4. Click **Add new** @@ -78,23 +82,26 @@ https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.google Domain-Wide Delegation must be configured by a Google Workspace **super administrator**. It may take a few minutes to propagate after saving. -### Step 6: Store Credentials Securely +### Step 6: Provide Credentials to Prowler -Set your credentials as environment variables: +- **Prowler Cloud:** Paste the Service Account JSON content and enter the delegated user email in the credentials form when configuring the Google Workspace provider. +- **Prowler CLI:** Export the credentials as environment variables: -```bash +```console export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/googleworkspace-sa.json" export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com" +prowler googleworkspace ``` -Alternatively, if you need to pass credentials as a string (e.g., in CI/CD pipelines): +Alternatively, to pass credentials as a string (e.g., in CI/CD pipelines): -```bash +```console export GOOGLEWORKSPACE_CREDENTIALS_CONTENT=$(cat /path/to/googleworkspace-sa.json) export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com" +prowler googleworkspace ``` -## Credential Lookup Order +## How Prowler Resolves Credentials Prowler resolves credentials in the following order: @@ -147,7 +154,7 @@ The Service Account cannot impersonate the delegated user. This usually means Do - All three required OAuth scopes are included - The delegated user is a super administrator -### Permission Denied on Admin SDK calls +### Permission Denied on Admin SDK Calls If Prowler connects but returns empty results or permission errors for specific API calls: diff --git a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx index 6f86bde4a8..361de533e1 100644 --- a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx +++ b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx @@ -1,100 +1,131 @@ --- -title: 'Getting Started with Google Workspace' +title: 'Getting Started With Google Workspace on Prowler' --- -import { VersionBadge } from "/snippets/version-badge.mdx"; +import { VersionBadge } from "/snippets/version-badge.mdx" - - -Prowler for Google Workspace allows you to audit your organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more. +Prowler for Google Workspace audits the organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more. ## Prerequisites -Before running Prowler with the Google Workspace provider, ensure you have: +Set up authentication for Google Workspace with the [Google Workspace Authentication](/user-guide/providers/googleworkspace/authentication) guide before starting either path: -1. A Google Workspace account with super administrator privileges -2. A Google Cloud Platform (GCP) project to host the Service Account -3. Authentication configured (see [Authentication](/user-guide/providers/googleworkspace/authentication)): - - A **Service Account JSON key** from a GCP project with Domain-Wide Delegation enabled +- **Service Account:** Create a Service Account in a GCP project with Domain-Wide Delegation enabled. +- **OAuth Scopes:** Authorize the required read-only OAuth scopes in the Google Workspace Admin Console. +- **Customer ID:** Identify the Google Workspace Customer ID to use as the provider identifier. +- **Delegated User:** Have the email of a super administrator to use as the delegated user. -## Quick Start + + + Onboard Google Workspace using Prowler Cloud + + + Onboard Google Workspace using Prowler CLI + + + +## Prowler Cloud + + + +### Step 1: Locate the Customer ID + +1. Log into the [Google Workspace Admin Console](https://admin.google.com). +2. Navigate to "Account" > "Account Settings". +3. Find the **Customer ID** on the Account Settings page. + + ![Google Workspace Customer ID](/images/providers/googleworkspace-customer-id.png) + + +The Customer ID starts with the letter "C" followed by alphanumeric characters (e.g., `C0xxxxxxx`). This value acts as the unique identifier for the Google Workspace account in Prowler Cloud. + + +### Step 2: Open Prowler Cloud + +1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). +2. Navigate to "Configuration" > "Cloud Providers". + + ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + +3. Click "Add Cloud Provider". + + ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + +4. Select "Google Workspace". + + ![Select Google Workspace](/images/providers/select-googleworkspace-prowler-cloud.png) + +### Step 3: Provide Credentials + +1. Enter the **Customer ID** and an optional alias, then click "Next". + + ![Google Workspace Customer ID Form](/images/providers/googleworkspace-customer-id-form.png) + +2. Paste the **Service Account JSON** credentials content. +3. Enter the "Delegated User Email" (a super administrator in the Google Workspace organization). + + ![Google Workspace Credentials Form](/images/providers/googleworkspace-credentials-form.png) + + +The Service Account JSON is the full content of the key file downloaded when creating the Service Account. Paste the entire JSON object, not just the file path. For setup instructions, see the [Authentication guide](/user-guide/providers/googleworkspace/authentication). + + +### Step 4: Check Connection + +1. Click "Check Connection" to verify that the credentials and Domain-Wide Delegation are configured correctly. +2. Prowler will test the Service Account impersonation and Admin SDK access. + + ![Check Connection](/images/providers/googleworkspace-check-connection.png) + + +If the connection test fails, verify that Domain-Wide Delegation is properly configured and that all three OAuth scopes are authorized. It may take a few minutes for delegation changes to propagate. See the [Troubleshooting](/user-guide/providers/googleworkspace/authentication#troubleshooting) section for common errors. + + +### Step 5: Launch the Scan + +1. Review the summary. +2. Click "Launch Scan" to start auditing Google Workspace. + + ![Launch Scan](/images/providers/googleworkspace-launch-scan.png) + +--- + +## Prowler CLI + + ### Step 1: Set Up Authentication -Set your Service Account credentials file path and delegated user email as environment variables: +Set your Service Account credentials and delegated user email following the [Google Workspace Authentication](/user-guide/providers/googleworkspace/authentication) guide: -```bash +```console export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json" export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com" ``` -### Step 2: Run Prowler - -```bash -prowler googleworkspace -``` - -Prowler will authenticate as the delegated user and run all available security checks against your Google Workspace organization. - -## Authentication - -Prowler uses a **Service Account with Domain-Wide Delegation** to authenticate to Google Workspace. This requires: - -- A Service Account created in a GCP project -- The Admin SDK API enabled in that project -- Domain-Wide Delegation configured in the Google Workspace Admin Console -- A super admin user email to impersonate - -### Using Environment Variables (Recommended) - -```bash -export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json" -export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com" -prowler googleworkspace -``` - Alternatively, pass the credentials content directly as a JSON string: -```bash +```console export GOOGLEWORKSPACE_CREDENTIALS_CONTENT='{"type": "service_account", ...}' export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com" +``` + +### Step 2: Run the First Scan + +Run a baseline scan after credentials are configured: + +```console prowler googleworkspace ``` - -The delegated user must be a super admin email in your Google Workspace organization. The service account credentials must be provided via environment variables (`GOOGLEWORKSPACE_CREDENTIALS_FILE` or `GOOGLEWORKSPACE_CREDENTIALS_CONTENT`). - +Prowler authenticates as the delegated user and runs all available security checks against the Google Workspace organization. -## Understanding the Output - -When Prowler runs successfully, it will display the credentials being used: - -``` -Using the Google Workspace credentials below: -┌─────────────────────────────────────────────────────────┐ -│ Google Workspace Domain: yourdomain.com │ -│ Customer ID: C0xxxxxxx │ -│ Delegated User: admin@yourdomain.com │ -│ Authentication Method: Service Account with Domain-Wide │ -│ Delegation │ -└─────────────────────────────────────────────────────────┘ -``` - -Findings are reported per check. For example, the `directory_super_admin_count` check verifies the number of super administrators is within a recommended range (2–4): - -- **PASS** — 2 to 4 super administrators found -- **FAIL** — 0 or 1 (single point of failure) or 5+ (excessive privilege exposure) - -Output files are saved in the configured output directory (default: `output/`) in CSV, JSON-OCSF, and HTML formats. - -## Configuration +### Step 3: Use a Custom Configuration (Optional) Prowler uses a configuration file to customize provider behavior. To use a custom configuration: -```bash +```console prowler googleworkspace --config-file /path/to/config.yaml ``` -## Next Steps - -- [Authentication](/user-guide/providers/googleworkspace/authentication) — Detailed guide on setting up a Service Account and Domain-Wide Delegation +--- diff --git a/docs/user-guide/providers/image/authentication.mdx b/docs/user-guide/providers/image/authentication.mdx new file mode 100644 index 0000000000..78dcd8318c --- /dev/null +++ b/docs/user-guide/providers/image/authentication.mdx @@ -0,0 +1,50 @@ +--- +title: "Image Authentication in Prowler" +--- + +Prowler's Image provider enables container image security scanning using [Trivy](https://trivy.dev/). No authentication is required for public images. Prowler supports the following authentication methods for private registries: + +* [**Basic Authentication (Environment Variables)**](https://trivy.dev/latest/docs/advanced/private-registries/docker-hub/): `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` +* [**Token-Based Authentication**](https://distribution.github.io/distribution/spec/auth/token/): `REGISTRY_TOKEN` +* [**Manual Docker Login**](https://docs.docker.com/reference/cli/docker/login/): Existing credentials in Docker's credential store + +Prowler uses the first available method in this priority order. + +## Basic Authentication (Environment Variables) + +To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler passes these credentials to Trivy, which handles authentication with the registry transparently: + +```bash +export REGISTRY_USERNAME="myuser" +export REGISTRY_PASSWORD="mypassword" + +prowler image -I myregistry.io/myapp:v1.0 +``` + +Both variables must be set for this method to activate. + +## Token-Based Authentication + +To authenticate using a registry token (such as a bearer or OAuth2 token), set the `REGISTRY_TOKEN` environment variable. Prowler passes the token directly to Trivy: + +```bash +export REGISTRY_TOKEN="my-registry-token" + +prowler image -I myregistry.io/myapp:v1.0 +``` + +This method is useful for registries that support token-based access without requiring a username and password. + +## Manual Docker Login (Fallback) + +If no environment variables are set, Prowler relies on existing credentials in Docker's credential store (`~/.docker/config.json`). To configure credentials manually before scanning: + +```bash +docker login myregistry.io + +prowler image -I myregistry.io/myapp:v1.0 +``` + + +This method is available in Prowler CLI only. In Prowler Cloud, use basic authentication or token-based authentication instead. + diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx index 821478efa6..9a3d67258d 100644 --- a/docs/user-guide/providers/image/getting-started-image.mdx +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -9,18 +9,69 @@ Prowler's Image provider enables comprehensive container image security scanning ## How It Works * **Trivy integration:** Prowler leverages [Trivy](https://trivy.dev/) to scan container images for vulnerabilities, secrets, misconfigurations, and license issues. -* **Trivy required:** Trivy must be installed and available in the system PATH before running any scan. * **Authentication:** No registry authentication is required for public images. For private registries, credentials can be provided via environment variables or manual `docker login`. + * Check the [Image Authentication](/user-guide/providers/image/authentication) page for more details. +* **Mutelist logic:** [Filtering](https://trivy.dev/latest/docs/configuration/filtering/) is handled by Trivy, not Prowler. * **Output formats:** Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.). + + + Scan container images using Prowler Cloud + + + Scan container images using Prowler CLI + + + +## Prowler Cloud + + + +### Supported Scanners + +Prowler Cloud does not support scanner selection. The vulnerability, secret, and misconfiguration scanners run automatically during each scan. + +### Step 1: Access Prowler Cloud + +1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) +2. Navigate to "Configuration" > "Cloud Providers" + + ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + +3. Click "Add Cloud Provider" + + ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + +4. Select "Container Registry" + + ![Select Container Registry](/user-guide/img/select-container-registry.png) + +5. Enter the container registry URL (e.g., `docker.io/myorg` or `myregistry.io`) and an optional alias, then click "Next" + + ![Add Container Registry URL](/user-guide/img/add-registry-url.png) + +### Step 2: Enter Authentication and Scan Filters + +6. Optionally provide [authentication](/user-guide/providers/image/authentication) credentials for private registries, then configure the following scan filters to control which images are scanned: + + * **Image filter:** A regex pattern to filter repositories by name (e.g., `^prod/.*`) + * **Tag filter:** A regex pattern to filter tags within repositories (e.g., `^(latest|v\d+\.\d+\.\d+)$`) + + Then click "Next" + + ![Image Authentication and Filters](/user-guide/img/image-authentication-filters.png) + +### Step 3: Verify Connection & Start Scan + +7. Review the provider configuration and click "Launch scan" to initiate the scan + + ![Verify Connection & Start Scan](/user-guide/img/image-verify-connection.png) + + ## Prowler CLI - -The Image provider is currently available in Prowler CLI only. - - ### Install Trivy Install Trivy using one of the following methods: @@ -55,7 +106,7 @@ Prowler CLI supports the following scanners: * [Misconfiguration](https://trivy.dev/docs/latest/guide/scanner/misconfiguration/) * [License](https://trivy.dev/docs/latest/guide/scanner/license/) -By default, only vulnerability and secret scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below. +By default, vulnerability, secret, and misconfiguration scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below. ### Scan Container Images @@ -112,7 +163,7 @@ Valid examples: #### Specify Scanners -To select which scanners Trivy runs, use the `--scanners` option. By default, Prowler enables `vuln` and `secret` scanners: +To select which scanners Trivy runs, use the `--scanners` option: ```bash # Vulnerability scanning only @@ -272,7 +323,7 @@ To scan images from private registries, the Image provider supports three authen #### 1. Basic Authentication (Environment Variables) -To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler automatically runs `docker login`, pulls the image, and performs a `docker logout` after the scan completes: +To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler passes these credentials to Trivy, which handles authentication with the registry transparently: ```bash export REGISTRY_USERNAME="myuser" @@ -281,7 +332,7 @@ export REGISTRY_PASSWORD="mypassword" prowler image -I myregistry.io/myapp:v1.0 ``` -Both variables must be set for this method to activate. Prowler handles the full lifecycle — login, pull, scan, and cleanup — without any manual Docker commands. +Both variables must be set for this method to activate. #### 2. Token-Based Authentication @@ -306,7 +357,7 @@ prowler image -I myregistry.io/myapp:v1.0 ``` -When basic authentication is active (method 1), Prowler automatically logs out from all authenticated registries after the scan completes. Manual `docker login` sessions (method 3) are not affected by this cleanup. +Credentials provided via environment variables are only passed to the Trivy subprocess and are not persisted beyond the scan. ### Troubleshooting Common Scan Errors diff --git a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx index 0ec8215776..c4f4822792 100644 --- a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx +++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx @@ -164,3 +164,7 @@ env: ``` + + +To set up a production-ready CronJob that runs Prowler on a schedule and sends findings to Prowler Cloud, see the [Run Kubernetes In-Cluster and Send Findings to Prowler Cloud](/user-guide/cookbooks/kubernetes-in-cluster) cookbook. + diff --git a/docs/user-guide/providers/microsoft365/authentication.mdx b/docs/user-guide/providers/microsoft365/authentication.mdx index 978003e262..460e44dba1 100644 --- a/docs/user-guide/providers/microsoft365/authentication.mdx +++ b/docs/user-guide/providers/microsoft365/authentication.mdx @@ -47,6 +47,9 @@ When using service principal authentication, add these **Application Permissions - `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. - `ThreatHunting.Read.All`: Required for Defender XDR checks (`defenderxdr_endpoint_privileged_user_exposed_credentials`, `defenderxdr_critical_asset_management_pending_approvals`). +- `DeviceManagementServiceConfig.Read.All`: Required for Intune device management settings used by `entra_conditional_access_policy_mdm_compliant_device_required` check. +- `DeviceManagementConfiguration.Read.All`: Required for Intune device compliance policies used by `entra_conditional_access_policy_mdm_compliant_device_required` check. +- `DeviceManagementManagedDevices.Read.All`: Required for Intune managed devices used by `entra_conditional_access_policy_mdm_compliant_device_required` check. **External API Permissions:** diff --git a/docs/user-guide/providers/openstack/getting-started-openstack.mdx b/docs/user-guide/providers/openstack/getting-started-openstack.mdx index e41806059a..b80ebe0e9f 100644 --- a/docs/user-guide/providers/openstack/getting-started-openstack.mdx +++ b/docs/user-guide/providers/openstack/getting-started-openstack.mdx @@ -4,13 +4,11 @@ title: 'Getting Started With OpenStack' import { VersionBadge } from "/snippets/version-badge.mdx" - +Prowler supports OpenStack both from the CLI and from Prowler Cloud. This guide walks you through the requirements, how to connect the provider in the UI, and how to run scans from the command line. -Prowler for OpenStack allows you to audit your OpenStack cloud infrastructure for security misconfigurations, including compute instances, networking, identity and access management, storage, and more. - - + Prowler currently supports **public cloud OpenStack providers** (OVH, Infomaniak, Vexxhost, etc.). Support for self-deployed OpenStack environments is not yet available, if you are interested in this feature, please [open an issue](https://github.com/prowler-cloud/prowler/issues/new) or [contact us](https://prowler.com/contact). - + ## Prerequisites @@ -22,16 +20,47 @@ Before running Prowler with the OpenStack provider, ensure you have: 4. Access to Prowler CLI (see [Installation](/getting-started/installation/prowler-cli)) or an account created in [Prowler Cloud](https://cloud.prowler.com) - - Run OpenStack security audits with Prowler CLI + + Onboard OpenStack using Prowler Cloud - - Learn about OpenStack authentication options + + Onboard OpenStack using Prowler CLI +## Prowler Cloud + + + +### Step 1: Add the Provider + +1. Navigate to "Cloud Providers" and click "Add Cloud Provider". + ![Providers List](./images/select-provider.png) +2. Select "OpenStack" from the provider list. +3. Enter the "Project ID" from the OpenStack provider. + ![Add project ID form](./images/add-provider-id.png) +4. (Optional) Add a friendly alias to identify this project in dashboards. + +### Step 2: Provide Credentials + +1. Click "Next" to open the credentials form. +2. Paste the full content of the `clouds.yaml` file into the "Clouds YAML Content" field. This file is available in the OpenStack provider's Horizon dashboard (see the [Authentication guide](/user-guide/providers/openstack/authentication) for detailed instructions). +3. Enter the "Cloud Name" — this is the key that identifies the cloud entry inside the `clouds.yaml` file (e.g., `mycloud`). + ![Credentials form](./images/add-credentials.png) + +### Step 3: Test the Connection and Start Scanning + +1. Click "Test connection" to ensure Prowler Cloud can reach the OpenStack API. + ![Test connection](./images/test-connection.png) +2. The provider will appear in the list with its current connection status. +3. Launch a scan from the provider row or from the "Scans" page. + +--- + ## Prowler CLI + + ### Step 1: Set Up Authentication Download the `clouds.yaml` file from your OpenStack provider (see [Authentication guide](/user-guide/providers/openstack/authentication) for detailed instructions) and save it to `~/.config/openstack/clouds.yaml`: @@ -98,7 +127,7 @@ Run a baseline scan of your OpenStack cloud: prowler openstack --clouds-yaml-cloud openstack ``` -Replace `openstack` with your cloud name if you customized it in the `clouds.yaml` file (e.g., `ovh-production`). +Replace `openstack` with the custom cloud name defined in the `clouds.yaml` file (e.g., `ovh-production`). Prowler will automatically discover and audit all supported OpenStack services in your project. diff --git a/docs/user-guide/providers/openstack/images/add-credentials.png b/docs/user-guide/providers/openstack/images/add-credentials.png new file mode 100644 index 0000000000..c2583e5358 Binary files /dev/null and b/docs/user-guide/providers/openstack/images/add-credentials.png differ diff --git a/docs/user-guide/providers/openstack/images/add-provider-id.png b/docs/user-guide/providers/openstack/images/add-provider-id.png new file mode 100644 index 0000000000..75266903fd Binary files /dev/null and b/docs/user-guide/providers/openstack/images/add-provider-id.png differ diff --git a/docs/user-guide/providers/openstack/images/select-provider.png b/docs/user-guide/providers/openstack/images/select-provider.png new file mode 100644 index 0000000000..3a280848c4 Binary files /dev/null and b/docs/user-guide/providers/openstack/images/select-provider.png differ diff --git a/docs/user-guide/providers/openstack/images/test-connection.png b/docs/user-guide/providers/openstack/images/test-connection.png new file mode 100644 index 0000000000..d41b38e0e0 Binary files /dev/null and b/docs/user-guide/providers/openstack/images/test-connection.png differ diff --git a/docs/user-guide/providers/vercel/authentication.mdx b/docs/user-guide/providers/vercel/authentication.mdx new file mode 100644 index 0000000000..32cb0256f9 --- /dev/null +++ b/docs/user-guide/providers/vercel/authentication.mdx @@ -0,0 +1,137 @@ +--- +title: "Vercel Authentication in Prowler" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Vercel authenticates using an **API Token**. + +## Required Permissions + +Prowler requires read-only access to Vercel teams, projects, deployments, domains, and security settings. The API Token must have access to the target team scope. + + +Vercel API Tokens inherit the permissions of the user that created them. Ensure the user has at least a **Viewer** role on the team to be scanned. + + +| Resource | Access | Description | +|----------|--------|-------------| +| Teams | Read | Required to list teams, members, and SSO configuration | +| Projects | Read | Required to list projects, environment variables, and deployment protection settings | +| Deployments | Read | Required to list deployments and protection status | +| Domains | Read | Required to list domains, DNS records, and SSL certificates | +| Firewall | Read | Required to read WAF rules, rate limiting, and IP blocking configuration | + +--- + +## API Token + +### Step 1: Create an API Token + +1. Log into the [Vercel Dashboard](https://vercel.com/dashboard). +2. Click the account avatar in the bottom-left corner and select "Settings". + + ![Vercel Account Settings](/user-guide/providers/vercel/images/vercel-account-settings.png) + +3. In the left sidebar, click "Tokens". +4. Under **Create Token**, enter a descriptive name (e.g., "Prowler Scan"). +5. Select the **Scope** — choose the team to be scanned or "Full Account" for all teams. +6. Set an **Expiration** date, or select "No expiration" for continuous scanning. +7. Click **Create**. + + ![Create Vercel Token](/user-guide/providers/vercel/images/vercel-create-token.png) + +8. Copy the token immediately. + + +Vercel only displays the token once. Copy it immediately and store it securely. If lost, a new token must be created. + + +### Step 2: Provide the Token to Prowler + +Export the token as an environment variable: + +```console +export VERCEL_TOKEN="your-api-token-here" +prowler vercel +``` + +--- + +## Team Scoping (Optional) + +By default, Prowler auto-discovers all teams the authenticated user belongs to and scans each one. To restrict the scan to a specific team, provide the Team ID. + +### Locate the Team ID + +1. In the Vercel Dashboard, navigate to "Settings" for the target team. +2. Scroll down to the **Team ID** section and copy the value. + + ![Vercel Team ID](/user-guide/providers/vercel/images/vercel-team-id.png) + +### Provide the Team ID to Prowler + +Export the Team ID as an environment variable: + +```console +export VERCEL_TOKEN="your-api-token-here" +export VERCEL_TEAM="team_yourteamid" +prowler vercel +``` + +--- + +## Environment Variables Reference + +| Variable | Required | Description | +|----------|----------|-------------| +| `VERCEL_TOKEN` | Yes | Vercel API Bearer Token | +| `VERCEL_TEAM` | No | Team ID or slug to scope the scan to a single team | + +--- + +## Best Practices + +- **Create a dedicated token for Prowler** — Avoid reusing tokens shared with other integrations. +- **Use environment variables** — Never hardcode credentials in scripts or commands. +- **Scope tokens to specific teams** — When possible, limit token access to the team being scanned. +- **Set token expiration** — Use time-limited tokens and rotate them regularly. +- **Use least privilege** — Assign the Viewer role to the user creating the token unless write access is explicitly needed. + +--- + +## Troubleshooting + +### "Vercel credentials not found" Error + +This error occurs when no API Token is provided. Ensure the `VERCEL_TOKEN` environment variable is set: + +```console +export VERCEL_TOKEN="your-api-token-here" +``` + +### "Invalid or expired Vercel API token" Error + +- Verify the API Token is correct and has not expired. +- Check that the token has not been revoked in the Vercel Dashboard under "Settings" > "Tokens". + +### "Insufficient permissions" Error + +- Ensure the user that created the token has at least a **Viewer** role on the target team. +- If scanning a specific team, verify the token scope includes that team. + +### "Team not found or not accessible" Error + +This error occurs when the provided `VERCEL_TEAM` value does not match an accessible team. Verify the Team ID is correct: + +1. Navigate to the team "Settings" in the Vercel Dashboard. +2. Copy the exact **Team ID** value from the settings page. + +### "Rate limit exceeded" Error + +Vercel applies rate limits to API requests. Prowler automatically retries rate-limited requests up to 3 times with exponential backoff. If this error persists: + +- Reduce the number of projects being scanned in a single run using the `--project` argument. +- Wait a few minutes and retry the scan. diff --git a/docs/user-guide/providers/vercel/getting-started-vercel.mdx b/docs/user-guide/providers/vercel/getting-started-vercel.mdx new file mode 100644 index 0000000000..67d4853d18 --- /dev/null +++ b/docs/user-guide/providers/vercel/getting-started-vercel.mdx @@ -0,0 +1,108 @@ +--- +title: "Getting Started With Vercel on Prowler" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + +Prowler for Vercel scans teams and projects for security misconfigurations, including deployment protection, environment variable exposure, WAF rules, domain configuration, team access controls, and more. + +## Prerequisites + +Set up authentication for Vercel with the [Vercel Authentication](/user-guide/providers/vercel/authentication) guide before starting: + +- Create a Vercel API Token with access to the target team +- Identify the Team ID (optional, required to scope the scan to a single team) + +## Prowler CLI + + + +### Step 1: Set Up Authentication + +Follow the [Vercel Authentication](/user-guide/providers/vercel/authentication) guide to create an API Token, then export it: + +```console +export VERCEL_TOKEN="your-api-token-here" +``` + +Optionally, scope the scan to a specific team: + +```console +export VERCEL_TEAM="team_yourteamid" +``` + +### Step 2: Run the First Scan + +Run a baseline scan after credentials are configured: + +```console +prowler vercel +``` + +Prowler automatically discovers all teams accessible with the provided token and runs security checks against them. + +### Step 3: Filter the Scan Scope (Optional) + +#### Filter by Team + +To scan a specific team, set the `VERCEL_TEAM` environment variable with the Team ID or slug: + +```console +export VERCEL_TEAM="team_yourteamid" +prowler vercel +``` + + +When no team is specified, Prowler auto-discovers all teams the authenticated user belongs to and scans each one. + + +#### Filter by Project + +To scan only specific projects, use the `--project` argument: + +```console +prowler vercel --project my-project-name +``` + +Multiple projects can be specified: + +```console +prowler vercel --project my-project-name another-project +``` + +Project IDs are also supported: + +```console +prowler vercel --project prj_abc123def456 +``` + +### Step 4: Use a Custom Configuration (Optional) + +Prowler uses a configuration file to customize provider behavior. The Vercel configuration includes: + +```yaml +vercel: + # Maximum number of retries for API requests (default is 3) + max_retries: 3 +``` + +To use a custom configuration: + +```console +prowler vercel --config-file /path/to/config.yaml +``` + +--- + +## Supported Services + +Prowler for Vercel includes security checks across the following services: + +| Service | Description | +|---------|-------------| +| **Authentication** | Token expiration and staleness checks | +| **Deployment** | Preview deployment access and production stability | +| **Domain** | DNS configuration, SSL certificates, and wildcard exposure | +| **Project** | Deployment protection, environment variable security, fork protection, and skew protection | +| **Security** | Web Application Firewall (WAF), rate limiting, IP blocking, and managed rulesets | +| **Team** | SSO enforcement, directory sync, member access, and invitation hygiene | diff --git a/docs/user-guide/providers/vercel/images/vercel-account-settings.png b/docs/user-guide/providers/vercel/images/vercel-account-settings.png new file mode 100644 index 0000000000..429c7a12f1 Binary files /dev/null and b/docs/user-guide/providers/vercel/images/vercel-account-settings.png differ diff --git a/docs/user-guide/providers/vercel/images/vercel-create-token.png b/docs/user-guide/providers/vercel/images/vercel-create-token.png new file mode 100644 index 0000000000..4aaf93da73 Binary files /dev/null and b/docs/user-guide/providers/vercel/images/vercel-create-token.png differ diff --git a/docs/user-guide/providers/vercel/images/vercel-team-id.png b/docs/user-guide/providers/vercel/images/vercel-team-id.png new file mode 100644 index 0000000000..c377439e7a Binary files /dev/null and b/docs/user-guide/providers/vercel/images/vercel-team-id.png differ diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx new file mode 100644 index 0000000000..646ee557de --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -0,0 +1,340 @@ +--- +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" + + + +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. + + + Attack Paths is currently available for **AWS** providers. Support for + additional providers is planned. + + +## Prerequisites + +The following prerequisites are required for Attack Paths: + +- **An AWS provider is configured** with valid credentials in Prowler 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. + +## How Attack Paths Scans Work + +Attack Paths scans are generated automatically when a security scan runs on an AWS provider. Each completed scan produces graph data that maps relationships between IAM principals, policies, trust configurations, and other resources. + +Once the scan finishes and the graph data is ready, the scan appears in the Attack Paths scan table with a **Completed** status. Scans that are still processing display as **Executing** or **Scheduled**. + + + Since Prowler scans all configured providers every **24 hours** by default, + Attack Paths data stays up to date automatically. + + +## Accessing Attack Paths + +To open Attack Paths, click **Attack Paths** in the left navigation menu. + +Attack Paths navigation menu entry + +The main interface is divided into two areas: + +- **Left panel:** A table listing all available Attack Paths scans +- **Right panel:** The query selector, parameter form, and execute controls + +## Selecting a Scan + +The scans table displays all Attack Paths scans with the following columns: + +- **Provider / Account:** The AWS provider alias and account identifier +- **Last scan date:** When the scan completed +- **Status:** Current state of the scan (Completed, Executing, Scheduled, or Failed) +- **Progress:** Completion percentage for in-progress scans +- **Duration:** Total scan time + +To select a scan for analysis, click **Select** on any row with a **Completed** status. + +Attack Paths scan list table showing completed scans + + + Only scans with a **Completed** status and ready graph data can be selected. + Scans that are still executing or have failed appear with disabled action + buttons. + + +## Choosing a Query + +After selecting a scan, the right panel activates a query dropdown. Each query targets a specific type of privilege escalation or misconfiguration pattern. + +To choose a query, click the dropdown and select from the available options. Each option displays: + +- **Query name:** A descriptive title (e.g., "IAM Privilege Escalation via AssumeRole") +- **Short description:** A brief summary of what the query detects + +Attack Paths query selector dropdown showing available queries + +Once selected, a description card appears below the dropdown with additional context about the query, including attribution links to external references when available. + +## Configuring Query Parameters + +Some queries accept optional or required parameters to narrow the scope of the analysis. When a query has parameters, a dynamic form appears below the query description. + +- **Required fields** are marked with an asterisk (\*) and must be filled before executing +- **Optional fields** refine the query results but are not mandatory + +If a query requires no parameters, the form displays a message confirming that the query is ready to execute. + +Attack Paths query parameter form with fields + +## Executing a Query + +To run the selected query against the scan data, click **Execute Query**. The button displays a loading state while the query processes. + +If the query returns no results, an informational message appears. Common reasons include: + +- **No matching patterns found:** The scanned environment does not contain the privilege escalation chain the query targets +- **Insufficient permissions:** The scan credentials may not have captured all the data the query needs + +Attack Paths right panel with query selected and execute button + +## Exploring the Graph + +After a successful execution, the graph visualization renders below the query builder in a full-width panel. The graph maps relationships between cloud resources, IAM entities, and security findings. + +### Node Types + +- **Resource nodes** (rounded pills): Represent cloud resources such as IAM roles, policies, EC2 instances, and S3 buckets. Each resource type has a distinct color. +- **Finding nodes** (hexagons): Represent Prowler security findings linked to resources in the graph. Colors indicate severity level (critical, high, medium, low). + +### Edge Types + +- **Solid lines:** Direct relationships between resources (e.g., a role attached to a policy) +- **Dashed lines:** Connections between resources and their associated findings + +A **legend** at the bottom of the graph lists all node types and edge types present in the current view. + +Attack Paths graph showing nodes, edges, and legend + +## Interacting with the Graph + +### Filtering by Node + +Click any node in the graph to filter the view and display only paths that pass through that node. When a filter is active: + +- An information banner shows which node is selected +- Click **Back to Full View** to restore the complete graph + +Attack Paths graph filtered to show paths through a selected node + +### Graph Controls + +The toolbar in the top-right corner of the graph provides: + +- **Zoom in / Zoom out:** Adjust the zoom level +- **Fit to screen:** Reset the view to fit all nodes +- **Export:** Download the current graph as an SVG file +- **Fullscreen:** Open the graph in a full-screen modal with a side-by-side node detail panel + + + Use **Ctrl + Scroll** (or **Cmd + Scroll** on macOS) to zoom directly within + the graph area. + + +## Viewing Node Details + +Click any node to open the **Node Details** panel below the graph. This panel displays: + +- **Node type:** The resource category (e.g., "IAM Role," "EC2 Instance") +- **Properties:** All attributes of the selected node, including identifiers, timestamps, and configuration details +- **Related findings** (for resource nodes): A list of Prowler findings linked to the resource, with severity, title, and status +- **Affected resources** (for finding nodes): A list of resources associated with the finding + +For finding nodes, a "View Finding" button links directly to the finding detail page for further investigation. + +Attack Paths node detail panel showing properties and related findings + +## Fullscreen Mode + +To expand the graph for detailed exploration, click the fullscreen icon in the graph toolbar. The fullscreen modal provides: + +- The full graph visualization with all zoom and export controls +- A side panel for node details that appears when a node is selected +- All filtering and interaction capabilities available in the standard view + +Attack Paths fullscreen mode with graph and node detail side panel + +## Using Attack Paths with the MCP Server and Lighthouse AI + +Attack Paths capabilities are also available through the [Prowler MCP Server](/getting-started/products/prowler-mcp), enabling interaction with Attack Paths data via AI assistants like Claude Desktop, Cursor, and other MCP clients. + +[Prowler Lighthouse AI](/getting-started/products/prowler-lighthouse-ai) also supports Attack Paths queries, allowing you to analyze privilege escalation chains and security misconfigurations directly from the chat interface. + +The following MCP tools are available for Attack Paths: + +- **`prowler_app_list_attack_paths_scans`** - List and filter Attack Paths scans +- **`prowler_app_list_attack_paths_queries`** - Discover available queries for a completed scan +- **`prowler_app_run_attack_paths_query`** - Execute a query and retrieve graph results with nodes and relationships +- **`prowler_app_get_attack_paths_cartography_schema`** - Retrieve the Cartography graph schema for custom openCypher queries + +### Example Questions + +Ask through the MCP Server or Lighthouse AI: + +- "Find EC2 instances exposed to the internet with access to sensitive S3 buckets" +- "Are there any IAM roles that can escalate their own privileges?" +- "Show me all internet-facing resources with open security groups" +- "Which principals can create Lambda functions with privileged roles?" +- "List all RDS instances with storage encryption disabled" +- "Find S3 buckets that allow anonymous access" +- "Are there any CloudFormation stacks that could be hijacked for privilege escalation?" +- "Show me all roles that can be assumed for lateral movement" + +### Supported Queries + +Attack Paths currently supports the following built-in queries for AWS: + +#### Custom Attack Path Queries + +| 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 | + +#### Network Exposure Queries + +| 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 | + +#### 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 | + +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 + +For the complete list of MCP tools, see the [Tools Reference](/getting-started/basic-usage/prowler-mcp-tools#attack-paths-analysis). diff --git a/docs/user-guide/tutorials/prowler-app-import-findings.mdx b/docs/user-guide/tutorials/prowler-app-import-findings.mdx index 2f04e7a2c8..48e6049943 100644 --- a/docs/user-guide/tutorials/prowler-app-import-findings.mdx +++ b/docs/user-guide/tutorials/prowler-app-import-findings.mdx @@ -132,69 +132,77 @@ Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF ## Required permissions -The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--export-ocsf`. +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`. For more information about RBAC permissions, refer to the [Prowler App RBAC documentation](/user-guide/tutorials/prowler-app-rbac). ## Using the CLI -The `--export-ocsf` flag uploads scan results directly to Prowler Cloud after a scan completes. This approach automates the ingestion process without manual file uploads. +The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after a scan completes. This approach automates the ingestion process without manual file uploads. ### Prerequisites - A valid Prowler Cloud API key (see [API Keys](/user-guide/tutorials/prowler-app-api-keys)) -- The `PROWLER_API_KEY` environment variable configured +- The `PROWLER_CLOUD_API_KEY` environment variable configured ### Basic usage ```bash -export PROWLER_API_KEY="pk_your_api_key_here" +export PROWLER_CLOUD_API_KEY="pk_your_api_key_here" -prowler aws --export-ocsf +prowler aws --push-to-cloud ``` ### Combining with output formats -When using `--export-ocsf` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload: +When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload: The temporary OCSF file is saved in the system temporary directory and not in the output path passed with `-o`. ```bash -prowler aws --services accessanalyzer -M csv --export-ocsf -o /tmp/scan-output +prowler aws --services accessanalyzer -M csv --push-to-cloud -o /tmp/scan-output ``` When default output formats include OCSF, Prowler reuses the existing file. Default output formats include JSON-OCSF: ```bash -prowler aws --services accessanalyzer --export-ocsf -o /tmp/scan-output +prowler aws --services accessanalyzer --push-to-cloud -o /tmp/scan-output ``` ### CLI output examples **Successful upload:** ``` -Exporting OCSF to Prowler Cloud, please wait... +Pushing findings to Prowler Cloud, please wait... -OCSF export accepted. Ingestion job: fa8bc8c5-4925-46a0-9fe0-f6575905e094 +Findings successfully pushed to Prowler Cloud. Ingestion job: fa8bc8c5-4925-46a0-9fe0-f6575905e094 +See more details here: https://cloud.prowler.com/scans ``` **Missing API key:** ``` -WARNING: OCSF export skipped: no API key configured. Set the PROWLER_API_KEY +Push to Prowler Cloud skipped: no API key configured. Set the PROWLER_CLOUD_API_KEY environment variable to enable it. Scan results were saved to /tmp/scan-output/prowler-output-123456789012-20260217131755.ocsf.json ``` **API unreachable:** ``` -WARNING: OCSF export skipped: could not reach the Prowler Cloud API at +Push to Prowler Cloud failed: could not reach the Prowler Cloud API at https://api.prowler.com. Check the URL and your network connection. Scan results were saved to /tmp/scan-output/prowler-output-123456789012-20260217131755.ocsf.json ``` +**No subscription:** +``` +Push to Prowler Cloud failed: this feature is only available with a Prowler Cloud +subscription. Scan results were saved to +/tmp/scan-output/prowler-output-123456789012-20260217131755.ocsf.json +``` + **Invalid API key:** ``` -WARNING: OCSF export failed: the API returned HTTP 401. Verify your API key is +Push to Prowler Cloud failed: the API returned HTTP 401. Verify your API key is valid and has the right permissions. Scan results were saved to /tmp/scan-output/prowler-output-123456789012-20260217131755.ocsf.json ``` @@ -212,10 +220,10 @@ The Ingestion API provides endpoints for submitting OCSF files and monitoring jo Include the API key in the `Authorization` header: ```bash -export PROWLER_API_KEY="pk_your_api_key_here" +export PROWLER_CLOUD_API_KEY="pk_your_api_key_here" curl -X POST \ - -H "Authorization: Api-Key ${PROWLER_API_KEY}" \ + -H "Authorization: Api-Key ${PROWLER_CLOUD_API_KEY}" \ -F "file=@/path/to/findings.ocsf.json" \ https://api.prowler.com/api/v1/ingestions ``` @@ -229,7 +237,7 @@ Upload a `.ocsf.json` file containing a JSON array of OCSF Detection Finding rec **Request:** ```bash curl -X POST \ - -H "Authorization: Api-Key ${PROWLER_API_KEY}" \ + -H "Authorization: Api-Key ${PROWLER_CLOUD_API_KEY}" \ -F "file=@scan-results.ocsf.json" \ https://api.prowler.com/api/v1/ingestions ``` @@ -267,7 +275,7 @@ Monitor the progress of an ingestion job. **Request:** ```bash curl -X GET \ - -H "Authorization: Api-Key ${PROWLER_API_KEY}" \ + -H "Authorization: Api-Key ${PROWLER_CLOUD_API_KEY}" \ -H "Accept: application/vnd.api+json" \ https://api.prowler.com/api/v1/ingestions/3650fef9-8e5f-4808-a95f-74f0afae8499 ``` @@ -319,7 +327,7 @@ Retrieve a list of ingestion jobs for the tenant. **Request:** ```bash curl -X GET \ - -H "Authorization: Api-Key ${PROWLER_API_KEY}" \ + -H "Authorization: Api-Key ${PROWLER_CLOUD_API_KEY}" \ -H "Accept: application/vnd.api+json" \ "https://api.prowler.com/api/v1/ingestions?filter[status]=completed&page[size]=10" ``` @@ -333,7 +341,7 @@ Retrieve error details for a specific ingestion job. **Request:** ```bash curl -X GET \ - -H "Authorization: Api-Key ${PROWLER_API_KEY}" \ + -H "Authorization: Api-Key ${PROWLER_CLOUD_API_KEY}" \ -H "Accept: application/vnd.api+json" \ https://api.prowler.com/api/v1/ingestions/3650fef9-8e5f-4808-a95f-74f0afae8499/errors ``` @@ -363,9 +371,9 @@ Prowler must be installed in the CI/CD environment before running scans. Refer t - name: Run Prowler and upload to Cloud env: - PROWLER_API_KEY: ${{ secrets.PROWLER_API_KEY }} + PROWLER_CLOUD_API_KEY: ${{ secrets.PROWLER_CLOUD_API_KEY }} run: | - prowler aws --services s3,iam --export-ocsf + prowler aws --services s3,iam --push-to-cloud ``` ### GitLab CI @@ -374,9 +382,9 @@ Prowler must be installed in the CI/CD environment before running scans. Refer t prowler_scan: script: - pip install prowler - - prowler aws --services s3,iam --export-ocsf + - prowler aws --services s3,iam --push-to-cloud variables: - PROWLER_API_KEY: $PROWLER_API_KEY + PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY ``` ## Billing impact @@ -392,6 +400,23 @@ For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing). ## Troubleshooting +### "Push to Prowler Cloud skipped: no API key configured" + +- Set the `PROWLER_CLOUD_API_KEY` environment variable before running the scan +- Verify the variable is exported and not empty + +### "Push to Prowler Cloud failed: could not reach the Prowler Cloud API" + +- Verify network connectivity to `api.prowler.com` +- Check firewall rules allow outbound HTTPS traffic +- Confirm the API endpoint is not blocked by proxy settings +- If using a custom base URL via `PROWLER_CLOUD_API_BASE_URL`, verify it is correct + +### "Push to Prowler Cloud failed: this feature is only available with a Prowler Cloud subscription" + +- The API returned HTTP 402, meaning your tenant does not have an active subscription +- Visit [Prowler Cloud Pricing](https://prowler.com/pricing) to review available plans + ### HTTP 401 Unauthorized - Verify the API key is valid and not revoked @@ -408,9 +433,3 @@ For pricing details, see [Prowler Cloud Pricing](https://prowler.com/pricing). - Check the `/api/v1/ingestions/{id}/errors` endpoint for details - Verify the OCSF file format is valid - Ensure the file contains Detection Finding records - -### CLI reports "could not reach the Prowler Cloud API" - -- Verify network connectivity to `api.prowler.com` -- Check firewall rules allow outbound HTTPS traffic -- Confirm the API endpoint is not blocked by proxy settings diff --git a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx index 19a31347c9..01779a6a96 100644 --- a/docs/user-guide/tutorials/prowler-app-jira-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-jira-integration.mdx @@ -79,6 +79,18 @@ Each Jira integration provides management actions through dedicated buttons: | **Enable/Disable** | Toggle integration status | • Enable or disable integration
| Status change takes effect immediately | | **Delete** | Remove integration permanently | • Permanently delete integration
• Remove all configuration data | ⚠️ **Cannot be undone** - confirm before deleting | +## Known Limitations + +### Issue Types with Required Custom Fields + +Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not currently populate when creating work items. If a selected issue type enforces required fields beyond the standard set (e.g., "Team", "Epic Name"), the work item creation will fail. + +To avoid this, select an issue type that does not require additional custom fields — **Task**, **Bug**, or **Story** typically work without restrictions. If unsure which issue types are available for a project, Prowler automatically fetches and displays them in the "Issue Type" selector when sending a finding. + + +Support for custom field mapping is planned for a future release. + + ## Troubleshooting ### Connection test fails diff --git a/docs/user-guide/tutorials/prowler-app-mute-findings.mdx b/docs/user-guide/tutorials/prowler-app-mute-findings.mdx index 64e9a0c8f4..82623b54cf 100644 --- a/docs/user-guide/tutorials/prowler-app-mute-findings.mdx +++ b/docs/user-guide/tutorials/prowler-app-mute-findings.mdx @@ -24,6 +24,11 @@ Advanced Mutelist enables users to create powerful, pattern-based muting rules u ## Prerequisites + +Advanced Mutelist requires the **Manage Account** permission. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details. + + + Before muting findings, ensure: - Valid access to Prowler App with appropriate permissions @@ -72,10 +77,10 @@ If the YAML configuration is invalid, an error message will be displayed 2. Navigate to the Findings page to verify muted findings ![Check muted findings](/images/mutelist-ui-9.png) - -The Advanced Mutelist configuration takes effect on subsequent scans. Existing findings are not retroactively muted. + +The Advanced Mutelist configuration takes effect on **subsequent scans only**. Existing findings from previous scans are **not** retroactively muted. You must run a new scan after saving your YAML configuration to see its effect. Similarly, removing a pattern from the YAML configuration will only stop muting new findings generated by subsequent scans. - + ## YAML Configuration Examples Below are ready-to-use examples for different cloud providers. For detailed syntax and logic explanation, see [CLI Mutelist documentation](/user-guide/cli/tutorials/mutelist#how-the-mutelist-works). @@ -416,6 +421,10 @@ Mutelist: Description: "Mute findings for dev/test environments in alpha project" ``` +### Priority: Advanced vs. Simple Mutelist + +When both Advanced Mutelist (YAML) and [Simple Mutelist](/user-guide/tutorials/prowler-app-simple-mutelist) rules match the same finding, the **Advanced Mutelist takes higher priority**. The finding will be muted with the reason "Muted by mutelist". If a finding is not matched by the Advanced Mutelist but matches a Simple Mutelist rule, the Simple rule's custom justification is used instead. + ### Best Practices 1. **Start Small**: Begin with specific resources and gradually expand diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index a5558503cf..8ac3a9f07b 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -238,6 +238,6 @@ To grant all administrative permissions, select the **Grant all admin permission The following permissions are available exclusively in **Prowler Cloud**: -**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--export-ocsf` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-app-import-findings) for details. +**Manage Ingestions:** Submit and manage findings ingestion jobs via the API. Required to upload OCSF scan results using the `--push-to-cloud` CLI flag or the ingestion endpoints. See [Import Findings](/user-guide/tutorials/prowler-app-import-findings) for details. **Manage Billing:** Access and manage billing settings, subscription plans, and payment methods. diff --git a/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx b/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx index d6226f7c5f..c0bd7db9b4 100644 --- a/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx +++ b/docs/user-guide/tutorials/prowler-app-simple-mutelist.mdx @@ -23,6 +23,11 @@ Simple Mutelist creates rules based on the finding's unique identifier (UID). Fo + +Simple Mutelist requires the **Manage Scans** permission. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details. + + + ## Accessing the Mutelist Page To access the Mutelist page: @@ -85,10 +90,10 @@ To toggle a mute rule without deleting it: 3. Locate the mute rule 4. Use the toggle switch in the "Enabled" column to enable or disable the rule - -Disabled mute rules remain in the system but do not affect findings. Findings associated with disabled rules will appear as unmuted in subsequent scans. + +Disabling a mute rule does not retroactively unmute existing findings that were already marked as muted. Those findings retain their muted status as point-in-time historical records. Only **new findings** generated by subsequent scans will appear as unmuted. - + ### Editing Mute Rules @@ -112,7 +117,7 @@ To permanently remove a mute rule: 5. Confirm the deletion -Deleting a mute rule is permanent. The finding will appear as unmuted in subsequent scans. To temporarily unmute a finding without losing the rule, disable the rule instead of deleting it. +Deleting a mute rule is permanent and cannot be undone. Existing findings that were already muted retain their muted status as historical records — only **new findings** from subsequent scans will appear as unmuted. To temporarily stop muting new findings without losing the rule, disable the rule instead of deleting it. @@ -124,9 +129,13 @@ Simple Mutelist creates mute rules based on a finding's unique identifier (UID). - **Historical findings** with the same UID are also muted - **Future findings** from subsequent scans are automatically muted if they match the UID +### Bulk Muting and Grouping + +When muting multiple findings at once, a single mute rule is created containing all selected finding UIDs. However, once a rule is created, **additional findings cannot be added to an existing rule**. To mute new findings, create a separate mute rule. + ### Uniqueness Constraint -Each finding UID can only have one mute rule. Attempting to create a duplicate mute rule for the same finding displays an error message indicating the rule already exists. +Each finding UID can only belong to one **enabled** mute rule at a time. Attempting to create a mute rule that includes a finding UID already covered by another enabled rule displays a conflict error. If you need to reorganize mute rules, disable or delete the existing rule first, then create a new one. ## Simple Mutelist vs. Advanced Mutelist @@ -134,8 +143,12 @@ Each finding UID can only have one mute rule. Attempting to create a duplicate m | ------------------------ | ----------------------------------------- | ------------------------------------------------------ | | **Configuration method** | Point-and-click interface | YAML configuration file | | **Muting scope** | Individual finding UIDs | Patterns based on checks, regions, resources, and tags | +| **When muting applies** | Immediately (current + historical findings) | On subsequent scans only (not retroactive) | +| **Unmuting behavior** | Disabling/deleting a rule only affects new findings from subsequent scans | Removing a pattern stops muting on the next scan | +| **Adding findings later** | Not supported — must create a new rule | Automatic — any finding matching the pattern is muted | | **Regular expressions** | Not supported | Fully supported | | **Bulk operations** | Checkbox selection in Findings table | YAML wildcards and patterns | +| **Priority** | Applied after Advanced Mutelist | Highest priority | | **Best for** | Quick, ad-hoc muting of specific findings | Complex, policy-driven muting rules | ### When to Use Simple Mutelist @@ -170,6 +183,14 @@ If an error indicates a mute rule already exists for a finding: 3. Edit the existing rule's justification if needed, or 4. Delete the existing rule and create a new one +### Finding Still Appears Muted After Disabling or Deleting a Rule + +If a finding still appears as muted after disabling or deleting its mute rule: + +1. This is expected behavior — existing findings retain their muted status as historical records +2. Run a new scan to generate new findings that will reflect the updated rule state +3. New findings with the same UID will appear with their actual status (PASS/FAIL) instead of muted + ### Finding Still Appears Unmuted If a muted finding still appears unmuted: diff --git a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx new file mode 100644 index 0000000000..2a349c0204 --- /dev/null +++ b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx @@ -0,0 +1,287 @@ +--- +title: 'SAML SSO: Google Workspace' +--- + +This page explains how to configure SAML-based Single Sign-On (SSO) in Prowler App using **Google Workspace** as the Identity Provider (IdP). The setup is divided into two parts: create a custom SAML app in Google Admin Console, then complete the configuration in Prowler App. + + +**Parallel Setup Required** + +Google Admin Console requires the ACS URL and Entity ID from Prowler App, while Prowler App displays these values only after opening the SAML configuration dialog. To work around this, open Prowler App in a separate browser tab, navigate to the profile page, open the "Configure SAML SSO" dialog, and copy the ACS URL and Entity ID before proceeding with the Google configuration. + + + +## Prerequisites + +- **Google Workspace**: Super Admin access (or delegated admin with app management permissions). +- **Prowler App**: Administrator access to the organization (role with "Manage Account" permission). +- Prowler App version **5.9.0** or later. + +--- + +## Part A - Google Admin Console + +### Step 1: Navigate to Web & Mobile Apps + +1. Go to [admin.google.com](https://admin.google.com). +2. In the left sidebar, navigate to **Apps > Web and mobile apps**. +3. Click "Add app", then select "Add custom SAML app". + +![Google Admin Console - Web & mobile apps](/images/prowler-app/saml/saml-sso-gw-1.png) + +### Step 2: Enter App Details + +1. In the **App name** field, enter a name (e.g., `Prowler`). +2. Optionally, add a description (e.g., `Prowler SAML APP`) and upload a logo. +3. Click "Continue". + +![Add custom SAML app - App details](/images/prowler-app/saml/saml-sso-gw-2.png) + +### Step 3: Download the IdP Metadata + +On the **Google Identity Provider details** screen: + +1. Google displays two options: + - **Option 1**: Click "Download Metadata" to save the XML file directly. This is the recommended approach. + - **Option 2**: Manually copy the **SSO URL**, **Entity ID**, and **Certificate**. +2. Download the metadata. This file is required to complete the Prowler App configuration in Part B. +3. Click "Continue". + +![Google Identity Provider details - Download metadata](/images/prowler-app/saml/saml-sso-gw-3.png) + + +**Save the Metadata File** + +Download and save the IdP metadata XML file before proceeding. This file cannot be easily retrieved later and is required to complete the SAML configuration in Prowler App. + + + +### Step 4: Configure the Service Provider Details + +Enter the following values obtained from the SAML SSO configuration dialog in Prowler App (see [Part B, Step 1](#step-1-open-the-saml-configuration-dialog) for details on where to find them): + +| Google Workspace Field | Value | +|------------------------|-------| +| **ACS URL** | The Assertion Consumer Service (ACS) URL displayed in Prowler App (e.g., `https://api.prowler.com/api/v1/accounts/saml/your-domain.com/acs/`). Self-hosted deployments use a different base URL. | +| **Entity ID** | The Audience URI displayed in Prowler App (e.g., `urn:prowler.com:sp`). | +| **Name ID format** | Select `EMAIL` from the dropdown. | +| **Name ID** | Select `Basic Information > Primary email` from the dropdown. | + +Click "Continue". + +![Service provider details - ACS URL, Entity ID, and Name ID configuration](/images/prowler-app/saml/saml-sso-gw-4.png) + +### Step 5: Configure Attribute Mapping + +To correctly provision users, configure the IdP to send the following attributes in the SAML assertion. The **App Attribute (SAML)** column lists the attribute names that Prowler expects. The **Google Directory Attribute** column shows a recommended source field, but any Google directory attribute can be used as long as it is mapped to the correct Prowler attribute name. + +Click "Add mapping" for each entry: + +| Google Directory Attribute | App Attribute (SAML) | Required | Notes | +|----------------------------|----------------------|----------|-------| +| `Basic Information > First name` | `firstName` | Yes | | +| `Basic Information > Last name` | `lastName` | Yes | | +| `Employee Details > Department` | `userType` | No | Determines the Prowler role. **Case-sensitive.** | +| `Employee Details > Organization` | `organization` | No | Company name displayed in Prowler App profile. | + + +**Remember the Mapped Fields** + +Take note of which Google directory attributes are mapped to each Prowler attribute. To update a user's role or organization in Prowler, modify the corresponding field in the user's Google Workspace profile (e.g., **Department** if mapped to `userType`). Changes propagate to Prowler on the next SAML login. + + + +Click "Finish" to create the SAML app. + +![Attribute mapping - Google Directory attributes to Prowler SAML attributes](/images/prowler-app/saml/saml-sso-gw-5.png) + + +**Dynamic Updates** + +Prowler App updates user attributes each time a user logs in. Any changes made in Google Workspace are reflected on the next login. + + + + +**Role Assignment via `userType`** + +The `userType` attribute controls which Prowler role is assigned to the user: + +- If `userType` matches an existing Prowler role name, the user receives that role automatically. +- If `userType` does not match any existing role, Prowler App creates a new role with that name **without permissions**. +- If `userType` is not set, the user receives the `no_permissions` role. + +In all cases where the resulting role has no permissions, a Prowler administrator must configure the appropriate permissions through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles. + + + +### Step 6: Enable the App for Users + +By default, newly created SAML apps have user access set to **OFF**. To enable access: + +1. Return to **Apps > Web and mobile apps** and select the Prowler SAML app. +2. Click "User access" (or "View details" under the "User access" section). +3. Set the service status to **ON for everyone**, or enable it for specific organizational units or groups. +4. Click "Save". + + ![Service Status - Set to ON for everyone](/images/prowler-app/saml/saml-sso-gw-17.png) + +5. Verify in the apps list that the "User access" column displays **"ON for everyone"**. + + ![Web & mobile apps list - User access confirmed as "ON for everyone"](/images/prowler-app/saml/saml-sso-gw-19.png) + + +**Propagation Delay** + +Changes to the app status can take up to 24 hours to propagate across Google Workspace, although they typically take effect within a few minutes. + + + + +**"Can't Test SAML Login" Error** + +If attempting to use the "Test SAML login" option in Google Admin Console and receiving a "Can't test SAML login" message, click "Allow Access" to enable the app for the organizational unit that includes the admin account. This is the same as setting the service status to **ON** as described above. + +![Test SAML login - Allow access prompt](/images/prowler-app/saml/saml-sso-gw-15.png) + + + +--- + +## Part B - Prowler App Configuration + +### Step 1: Open the SAML Configuration Dialog + +1. Navigate to the profile settings page: + - **Prowler Cloud**: `https://cloud.prowler.com/profile` + - **Self-hosted**: `http://{your-domain}/profile` +2. Find the "SAML SSO Integration" card and click "Enable" (or "Update" if already configured). +3. The "Configure SAML SSO" dialog opens, displaying: + - **ACS URL**: The Assertion Consumer Service URL (copy this value for Part A, Step 4). This URL updates dynamically when the email domain is entered. + - **Audience**: The Entity ID (copy this value for Part A, Step 4). + - **Name ID Format**: The expected format (`urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`). + - **Supported Assertion Attributes**: The list of accepted attributes (`firstName`, `lastName`, `userType`, `organization`). + +![Prowler App - Configure SAML SSO dialog (initial state)](/images/prowler-app/saml/saml-sso-gw-prowler-1.png) + +### Step 2: Enter the Email Domain and Upload Metadata + +1. Enter the **email domain** for the organization (e.g., `prowler.cloud`). Prowler App uses this domain to identify users who should authenticate via SAML. The ACS URL updates automatically to reflect the configured domain. +2. Upload the **metadata XML file** downloaded in Part A, Step 3. +3. Click "Save". + +![Prowler App - Configure SAML SSO dialog (domain entered and ready to save)](/images/prowler-app/saml/saml-sso-gw-prowler-2.png) + +### Step 3: Verify the Enabled Status + +The "SAML SSO Integration" card should now display a **"Status: Enabled"** indicator with a checkmark, confirming that the configuration is complete. + +![Prowler App - SAML SSO Integration status showing "Enabled"](/images/prowler-app/saml/saml-sso-gw-prowler-3.png) + +--- + +## Testing the Integration + +### Optional: Create a Test User in Google Workspace + +To verify the integration without affecting existing users, create a dedicated test user in Google Admin Console: + +1. Navigate to **Directory > Users** in Google Admin Console. +2. Click "Add new user". + + ![Google Admin Console - Users directory](/images/prowler-app/saml/saml-sso-gw-7.png) + +3. Fill in the user details (first name, last name, and primary email address in the configured domain). + + ![Add new user form](/images/prowler-app/saml/saml-sso-gw-8.png) + +4. Complete the user creation. Google Workspace generates temporary credentials for the new account. + + ![User created successfully - Username and temporary password](/images/prowler-app/saml/saml-sso-gw-10.png) + +### Optional: Configure User Attributes for Role Mapping + +To test the `userType` → role mapping, set the **Department** attribute in the test user's profile. This value is sent as the `userType` SAML attribute based on the mapping configured in Part A, Step 5. + +1. In **Directory > Users**, click the test user's name to open the profile. +2. Click "User details", scroll to **Employee information**, and enter a value in the **Department** field (e.g., `Backend`). This value determines the Prowler role assigned to the user. +3. Click "Save". + + ![User information - Setting Department to "Backend" for userType mapping](/images/prowler-app/saml/saml-sso-gw-13.png) + +### SP-Initiated SSO (from Prowler) + +1. Navigate to the Prowler login page. +2. Click "Continue with SAML SSO". +3. Enter an email from the configured domain (e.g., `adrian@prowler.cloud`). +4. Click "Log in". The browser redirects to Google for authentication and returns to Prowler App upon success. + +![Prowler App - Sign in with SAML SSO](/images/prowler-app/saml/saml-sso-gw-prowler-4.png) + +### Verify User Profile and Role Mapping + +After a successful SSO login, the user profile in Prowler App reflects the attributes sent by Google Workspace: + +- **Name**: Populated from the `firstName` and `lastName` attributes. +- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with no permissions by default. +- **Permissions**: In the screenshot below, the user has no permissions because the `Backend` role did not exist prior to login and was created automatically without any permissions. To resolve this, a Prowler administrator can either: + - Assign the appropriate permissions to the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. + - Set the `userType` attribute in the IdP to match an existing Prowler role that already has the desired permissions. The updated role is applied on the next SAML login. + +For more details on role assignment behavior and attribute mapping, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#configure-attribute-mapping-in-the-idp) page. + +![Prowler App - User profile showing role "Backend" created from userType mapping](/images/prowler-app/saml/saml-sso-gw-prowler-5.png) + +### IdP-Initiated SSO (from Google) + +1. Sign in to Google Workspace with an account that has access to the Prowler SAML app. +2. Open the Google Workspace app launcher (the grid icon in the top-right corner of any Google page). +3. Click the Prowler app tile. +4. The browser redirects directly to Prowler App, authenticated. + +For more information on the SSO login flows, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#idp-initiated-sso) page. + +--- + +## Troubleshooting + + +**User Lockout After Misconfiguration** + +If SAML is configured with incorrect metadata or an incorrect domain, users who authenticated via SAML cannot fall back to password login. A Prowler administrator must remove the SAML configuration via the API: + +```bash +curl -X DELETE 'https://api.prowler.com/api/v1/saml-config' \ + -H 'Authorization: Bearer ' \ + -H 'Accept: application/vnd.api+json' +``` + +After removal, affected users must reset their password to regain access using standard email and password login. This also applies when SAML is intentionally removed - all SAML-authenticated users need to reset their password. For more details, refer to the [SAML API Reference](/user-guide/tutorials/prowler-app-sso#saml-api-reference). For additional support, contact [Prowler Support](https://docs.prowler.com/user-guide/contact-support). + + + + +**Email Domain Uniqueness** + +Prowler does not allow two tenants to share the same email domain. If the domain is already associated with another tenant, the configuration will fail. This is by design to prevent authentication ambiguity. + + + + +**Just-in-Time Provisioning** + +Users who authenticate via SAML for the first time are automatically created in Prowler App. No prior invitation is needed. User attributes (`firstName`, `lastName`, `userType`) are updated on every login from the Google directory. + + + +--- + +## Quick Summary + +1. In **Google Admin Console**, create a custom SAML app using the ACS URL and Entity ID from Prowler App. +2. Configure **attribute mapping**: `firstName`, `lastName`, and optionally `userType` and `organization`. +3. **Download the metadata XML** from Google. +4. **Enable the app** in Google Workspace for the relevant users or groups. +5. In **Prowler App**, enter the email domain, upload the metadata XML, and save. +6. Verify the SAML SSO Integration shows **"Status: Enabled"**. +7. Test login via "Continue with SAML SSO" on the Prowler login page. diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index f823fae3df..d4d2c18d11 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -75,7 +75,7 @@ Choose a Method: **IdP Configuration** - The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. For SSO integration with Azure AD / Entra ID, see our [Entra ID configuration instructions](/user-guide/tutorials/prowler-app-sso-entra). + The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, Google Workspace, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. For SSO integration with Azure AD / Entra ID, see our [Entra ID configuration instructions](/user-guide/tutorials/prowler-app-sso-entra). For Google Workspace, see our [Google Workspace configuration instructions](/user-guide/tutorials/prowler-app-sso-google-workspace). @@ -88,7 +88,7 @@ Choose a Method: | `firstName` | The user's first name. | Yes | | `lastName` | The user's last name. | Yes | | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name without permissions. If `userType` is not defined, the user is assigned the `no_permissions` role. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | - | `companyName` | The user's company name. This is automatically populated if the IdP sends an `organization` attribute. | No | + | `organization` | The user's company name. | No | **IdP Attribute Mapping** diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index fba2d98f6c..e56627ee20 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -27,7 +27,7 @@ This feature is **exclusively available in Prowler Cloud**. For CLI-based multi- Before using the AWS Organizations wizard, you need to deploy **two IAM roles** in your AWS environment. The onboarding follows this sequence: - Onboarding flow: 1. Create Management Account Role, 2. Deploy StackSet, 3. Run the Wizard, 4. Launch Scans + Onboarding flow: 1. Create Management Account Role (Quick Create or Manual), 2. Deploy StackSet, 3. Run the Wizard, 4. Launch Scans ## Key Concepts @@ -46,11 +46,11 @@ Prowler requires **two separate IAM roles** deployed in different places, each w | Role | Where it lives | What it does | How to deploy it | |------|---------------|--------------|------------------| -| **ProwlerScan** (management account) | Your management (root) account only | Discovers the Organization structure **and** scans the management account. Has additional Organizations discovery permissions. | **Manually** in the IAM Console ([Step 1](#step-1-create-the-management-account-role)). Cannot be deployed via StackSet. | +| **ProwlerScan** (management account) | Your management (root) account only | Discovers the Organization structure **and** scans the management account. Has additional Organizations discovery permissions. | Via **Quick Create** link or **manually** in the IAM Console ([Step 1](#step-1-create-the-management-account-role)). Cannot be deployed via StackSet. | | **ProwlerScan** (member accounts) | Every member account | Scans the account for security findings. | Via **CloudFormation StackSet** ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Automated across all accounts. | - Two Roles Architecture: ProwlerScan in management account (discovery + scanning) and ProwlerScan in member accounts (scanning only) + Two Roles Architecture: ProwlerScan in management account (Quick Create or Manual, discovery + scanning) and ProwlerScan in member accounts (via StackSet, scanning only) @@ -76,14 +76,31 @@ Your AWS environment must have [AWS Organizations](https://docs.aws.amazon.com/o The first role you need to create is the **management account role**. This role allows Prowler to discover your Organization structure — listing accounts, OUs, and hierarchy. -**This role must be created manually.** Organizational CloudFormation StackSets do not deploy to the management account itself — this is an AWS limitation, not a Prowler one. StackSets with service-managed permissions only target member accounts. Similarly, the Prowler Quick Create link only deploys the role to member accounts. +**StackSets do not deploy to the management account.** Organizational CloudFormation StackSets with service-managed permissions only target member accounts — this is an AWS limitation, not a Prowler one. You must create the management account role separately, either via the Quick Create link ([Option A](#option-a-quick-create-link-fastest)) or manually ([Option B](#option-b-create-the-role-manually)). **The role must be named `ProwlerScan`** — the same name as the role deployed to member accounts via StackSet. Prowler expects a consistent role name across all accounts in the Organization. If you use a different name, connection tests and scans will fail for the management account. -### Create the IAM Role +### Option A: Quick Create Link (Fastest) + +The Prowler wizard provides a one-click link that opens the AWS Console with the CloudFormation template pre-configured. This creates a **CloudFormation Stack** (not a StackSet) that deploys the ProwlerScan role with Organizations permissions enabled in your management account. + + +**[Open Quick Create Stack in AWS Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler¶m_EnableOrganizations=true)** + +Opens the CloudFormation Console with the Prowler scan role template and `EnableOrganizations=true` pre-filled. You will need to enter the **ExternalId** parameter manually — copy it from the Prowler wizard ([Step 4](#step-4-authenticate-with-your-management-account)). + + +1. Click **[Open Quick Create Stack in AWS Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler¶m_EnableOrganizations=true)** or use the **Create Stack in Management Account** button in the Prowler wizard (which also pre-fills the ExternalId). +2. Enter the **ExternalId** parameter if not pre-filled. +3. Check **"I acknowledge that AWS CloudFormation might create IAM resources with custom names"** and click **Create stack**. +4. Wait for the stack to reach **CREATE_COMPLETE** status. + +Take note of the **Role ARN** from the stack's **Outputs** tab — you will need it in the wizard. + +### Option B: Create the Role Manually 1. Sign in to the [AWS IAM Console](https://console.aws.amazon.com/iam/) in your **management account**. @@ -181,43 +198,26 @@ The StackSet uses **service-managed permissions**, which means AWS Organizations **Trusted access required:** CloudFormation StackSets must have trusted access enabled in your management account. Verify this in the AWS Console under **AWS Organizations > Settings > Trusted access for AWS CloudFormation StackSets**. -### Option A: Using the Prowler Quick Create Link (Recommended) - -The Prowler wizard provides a one-click link that opens the AWS Console with everything pre-configured. + +**The Quick Create link creates a Stack, not a StackSet.** The link in the Prowler wizard creates a CloudFormation **Stack** that deploys the ProwlerScan role in your management account only ([Step 1](#step-1-create-the-management-account-role)). To deploy the role across **member accounts**, you must create a StackSet manually as described below. AWS does not support Quick Create links for StackSets. + -**[Open Quick Create in AWS Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Faws%2Fcloudformation%2Fprowler-scan-role.yml&stackName=Prowler)** +**[Open StackSets Console →](https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacksets/create)** -Opens the CloudFormation Console with the Prowler scan role template and parameters pre-configured. You can also find this link in the Prowler wizard during [Step 4: Authentication](#step-4-authenticate-with-your-management-account). +Opens the CloudFormation StackSets creation page directly. You will need to paste the template URL and ExternalId manually. -1. Review the pre-filled parameters: - - **Template URL**: Points to the official [Prowler scan role template](https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml) hosted on Prowler's public S3 bucket. - - **ExternalId**: Pre-filled with your tenant's External ID when clicking the link from the Prowler Cloud wizard. If you open this link directly, you will need to enter the External ID manually. - - {/* TODO: screenshot of AWS Console Quick Create page showing pre-filled parameters */} - -2. Under **Deployment targets**, select: - - **Deploy to organization** to deploy to all accounts, or - - **Deploy to organizational units (OUs)** and specify the OU IDs you want to cover. - -3. Review the settings and click **Create StackSet**. AWS will begin deploying the ProwlerScan role to every target account. - -### Option B: Manual StackSet Deployment - -If you prefer full control over the deployment: - -1. Open the [AWS CloudFormation Console](https://console.aws.amazon.com/cloudformation/) in your management account. -2. Go to **StackSets > Create StackSet**. -3. Choose **Service-managed permissions**. -4. Use this template URL: +1. Click the link above or navigate to **CloudFormation > StackSets > Create StackSet** in your management account. +2. Choose **Service-managed permissions**. +3. Select **Amazon S3 URL** as the template source and paste the following URL: ``` https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml ``` -5. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard. -6. Choose your deployment targets (entire organization or specific OUs). -7. Select the AWS regions where you want the role deployed. -8. Click **Create StackSet**. +4. Set the **ExternalId** parameter to the External ID shown in the Prowler wizard. +5. Choose your deployment targets (entire organization or specific OUs). +6. Select the AWS regions where you want the role deployed. +7. Click **Create StackSet**. ### Verify StackSet Deployment @@ -281,32 +281,42 @@ Click **Next** to proceed to the authentication phase. ## Step 4: Authenticate with Your Management Account -### Copy the External ID +The wizard's **Authentication Details** page guides you through three actions: deploying the roles in AWS, entering the management account Role ARN, and confirming the deployment. -The wizard displays a **Prowler External ID** — auto-generated and unique to your tenant. Click the copy icon to copy it. If you haven't already configured the trust policy on your management account role ([Step 1](#step-1-create-the-management-account-role)), do so now using this External ID. +### External ID + +The wizard displays a **Prowler External ID** at the top — auto-generated and unique to your tenant. Click the copy icon to copy it. You will need this External ID for both the management account Stack and the member accounts StackSet. + +### Deploy the Roles + +The wizard provides two deployment actions: + +1. **Create Stack in Management Account** — opens a Quick Create link that deploys the ProwlerScan role with `EnableOrganizations=true` in your management account ([Step 1](#step-1-create-the-management-account-role)). The External ID is pre-filled. + +2. **Open StackSets Console** — links to the CloudFormation StackSets console where you create a StackSet for member accounts ([Step 2](#step-2-deploy-the-cloudformation-stackset)). Copy the template URL shown in the wizard and paste the External ID manually. - Authentication Details form showing External ID, Role ARN field, and StackSet confirmation checkbox + Authentication Details form showing External ID, two deployment buttons (Create Stack in Management Account and Open StackSets Console), Management Account Role ARN field, and deployment confirmation checkbox -### Enter the Role ARN +### Enter the Management Account Role ARN -Paste the **Role ARN** of the management account role you created in [Step 1](#step-1-create-the-management-account-role) into the **Role ARN** field. +Paste the **Role ARN** of the management account role you created in [Step 1](#step-1-create-the-management-account-role) into the **Management Account Role ARN** field. The ARN follows this format: ``` -arn:aws:iam:::role/ +arn:aws:iam:::role/ProwlerScan ``` For example: `arn:aws:iam::123456789012:role/ProwlerScan` - Role ARN field in the Authentication Details form + Management Account Role ARN field in the Authentication Details form ### Confirm and Discover -1. Check the box: **"The StackSet has been successfully deployed in AWS"**. +1. Check the box: **"The Stack and StackSet have been successfully deployed in AWS"**. 2. Click **Authenticate**. Here's what happens behind the scenes: @@ -314,8 +324,6 @@ Here's what happens behind the scenes: - An asynchronous discovery is triggered to query your AWS Organization structure. - You will see a **"Gathering AWS Accounts..."** spinner — this typically takes **30 seconds to 2 minutes** depending on your organization size. -{/* TODO: screenshot of the Authentication Details form with the spinner */} - ## Step 5: Select Accounts to Scan ### Understanding the Tree View @@ -364,8 +372,6 @@ Some accounts may appear as **blocked** (grayed out, not selectable). This happe Hover over the blocked account to see the specific reason. -{/* TODO: screenshot of the tree view with account selection, showing active, already-connected, and blocked accounts */} - ## Step 6: Test Connections ### How Connection Testing Works @@ -452,8 +458,6 @@ Scans are only launched for accounts that are accessible (passed connection test - Results populate the **Overview** and **Findings** pages. - Prowler runs an **automatic sync every 6 hours** to detect new accounts added to your Organization or accounts that have been removed. New accounts are onboarded automatically based on the parent OU configuration. -{/* TODO: screenshot of the Launch Scan step */} - ## Billing Impact Each AWS account you connect through the Organizations wizard counts as one **provider** in your Prowler Cloud subscription. diff --git a/mcp_server/CHANGELOG.md b/mcp_server/CHANGELOG.md index 5627cf10f3..21aa71dbf2 100644 --- a/mcp_server/CHANGELOG.md +++ b/mcp_server/CHANGELOG.md @@ -2,11 +2,29 @@ All notable changes to the **Prowler MCP Server** are documented in this file. +## [0.6.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Resource events tool to get timeline for a resource (who, what, when) [(#10412)](https://github.com/prowler-cloud/prowler/pull/10412) + +### 🔐 Security + +- `authlib` bumped from 1.6.5 to 1.6.9 to fix CVE-2026-28802 (JWT `alg: none` validation bypass) [(#10579)](https://github.com/prowler-cloud/prowler/pull/10579) + +--- + +## [0.5.0] (Prowler v5.21.0) + +### 🚀 Added + +- Attack Path tool to get Neo4j DB schema [(#10321)](https://github.com/prowler-cloud/prowler/pull/10321) + ## [0.4.0] (Prowler v5.19.0) ### 🚀 Added -- MCP Server tools for Prowler Attack Paths [(#10145)](https://github.com/prowler-cloud/prowler/pull/10145) +- Attack Paths tools to list scans, discover queries, and run Cypher queries against Neo4j [(#10145)](https://github.com/prowler-cloud/prowler/pull/10145) --- diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index d075e83b5f..d8377a1762 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -1,7 +1,7 @@ # ============================================================================= # Build stage - Install dependencies and build the application # ============================================================================= -FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder +FROM ghcr.io/astral-sh/uv:python3.13-alpine@sha256:8f53782bb232ab0b5558f3071e86e2bbfde884e18815f2b19cc57f2d336e9ee2 AS builder WORKDIR /app @@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # ============================================================================= # Final stage - Minimal runtime environment # ============================================================================= -FROM python:3.13-alpine +FROM python:3.13-alpine@sha256:bb1f2fdb1065c85468775c9d680dcd344f6442a2d1181ef7916b60a623f11d40 LABEL maintainer="https://github.com/prowler-cloud" diff --git a/mcp_server/prowler_mcp_server/__init__.py b/mcp_server/prowler_mcp_server/__init__.py index 5fcfa74c55..fe7af2dcea 100644 --- a/mcp_server/prowler_mcp_server/__init__.py +++ b/mcp_server/prowler_mcp_server/__init__.py @@ -5,7 +5,7 @@ This package provides MCP tools for accessing: - Prowler Hub: All security artifacts (detections, remediations and frameworks) supported by Prowler """ -__version__ = "0.4.0" +__version__ = "0.5.0" __author__ = "Prowler Team" __email__ = "engineering@prowler.com" diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py index bc048918aa..cfd704eeca 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/attack_paths.py @@ -118,6 +118,51 @@ class AttackPathScansListResponse(BaseModel): ) +class AttackPathCartographySchema(MinimalSerializerMixin, BaseModel): + """Cartography graph schema metadata for a completed attack paths scan. + + Contains the schema URL and provider info needed to fetch the full + Cartography schema markdown for openCypher query generation. + """ + + model_config = ConfigDict(frozen=True) + + id: str = Field(description="Unique identifier for the schema resource") + provider: str = Field(description="Cloud provider type (aws, azure, gcp, etc.)") + cartography_version: str = Field(description="Version of the Cartography schema") + schema_url: str = Field(description="URL to the Cartography schema page on GitHub") + raw_schema_url: str = Field( + description="Raw URL to fetch the Cartography schema markdown content" + ) + schema_content: str | None = Field( + default=None, + description="Full Cartography schema markdown content (populated after fetch)", + ) + + @classmethod + def from_api_response( + cls, response: dict[str, Any] + ) -> "AttackPathCartographySchema": + """Transform JSON:API schema response to model. + + Args: + response: Full API response with data and attributes + + Returns: + AttackPathCartographySchema instance + """ + data = response.get("data", {}) + attributes = data.get("attributes", {}) + + return cls( + id=data["id"], + provider=attributes["provider"], + cartography_version=attributes["cartography_version"], + schema_url=attributes["schema_url"], + raw_schema_url=attributes["raw_schema_url"], + ) + + class AttackPathQueryParameter(MinimalSerializerMixin, BaseModel): """Parameter definition for an attack paths query. diff --git a/mcp_server/prowler_mcp_server/prowler_app/models/resources.py b/mcp_server/prowler_mcp_server/prowler_app/models/resources.py index 46d68555ea..823134794f 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/models/resources.py +++ b/mcp_server/prowler_mcp_server/prowler_app/models/resources.py @@ -135,3 +135,48 @@ class ResourcesMetadataResponse(BaseModel): regions=attributes.get("regions"), types=attributes.get("types"), ) + + +class ResourceEvent(MinimalSerializerMixin, BaseModel): + """A cloud API action performed on a resource. + + Sourced from cloud provider audit logs (AWS CloudTrail, Azure Activity Logs, + GCP Audit Logs, etc.). + """ + + id: str + event_time: str + event_name: str + event_source: str + actor: str + actor_uid: str | None = None + actor_type: str | None = None + source_ip_address: str | None = None + user_agent: str | None = None + request_data: dict | None = None + response_data: dict | None = None + error_code: str | None = None + error_message: str | None = None + + @classmethod + def from_api_response(cls, data: dict) -> "ResourceEvent": + """Transform JSON:API resource event response.""" + return cls(id=data["id"], **data.get("attributes", {})) + + +class ResourceEventsResponse(BaseModel): + """Response wrapper for resource events list.""" + + events: list[ResourceEvent] + total_events: int + + @classmethod + def from_api_response(cls, response: dict) -> "ResourceEventsResponse": + """Transform JSON:API response to events list.""" + data = response.get("data", []) + events = [ResourceEvent.from_api_response(item) for item in data] + + return cls( + events=events, + total_events=len(events), + ) diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py index 8d7119b9cc..ff9b8045a4 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/attack_paths.py @@ -8,6 +8,7 @@ through cloud infrastructure relationships. from typing import Any, Literal from prowler_mcp_server.prowler_app.models.attack_paths import ( + AttackPathCartographySchema, AttackPathQuery, AttackPathQueryResult, AttackPathScansListResponse, @@ -225,3 +226,53 @@ class AttackPathsTools(BaseTool): f"Failed to run attack paths query '{query_id}' on scan {scan_id}: {e}" ) return {"error": f"Failed to run attack paths query '{query_id}': {str(e)}"} + + async def get_attack_paths_cartography_schema( + self, + scan_id: str = Field( + description="UUID of a COMPLETED attack paths scan. Use `prowler_app_list_attack_paths_scans` with state=['completed'] to find scan IDs" + ), + ) -> dict[str, Any]: + """Retrieve the Cartography graph schema for a completed attack paths scan. + + This tool fetches the full Cartography schema (node labels, relationships, + and properties) so the LLM can write accurate custom openCypher queries + for attack paths analysis. + + Two-step flow: + 1. Calls the Prowler API to get schema metadata (provider, version, URLs) + 2. Fetches the raw Cartography schema markdown from GitHub + + Returns: + - id: Schema resource identifier + - provider: Cloud provider type + - cartography_version: Schema version + - schema_url: GitHub page URL for reference + - raw_schema_url: Raw markdown URL + - schema_content: Full Cartography schema markdown with node/relationship definitions + + Workflow: + 1. Use prowler_app_list_attack_paths_scans to find a completed scan + 2. Use this tool to get the schema for the scan's provider + 3. Use the schema to craft custom openCypher queries + 4. Execute queries with prowler_app_run_attack_paths_query + """ + try: + api_response = await self.api_client.get( + f"/attack-paths-scans/{scan_id}/schema" + ) + + schema = AttackPathCartographySchema.from_api_response(api_response) + + schema_content = await self.api_client.fetch_external_url( + schema.raw_schema_url + ) + + return schema.model_copy( + update={"schema_content": schema_content} + ).model_dump() + except Exception as e: + self.logger.error( + f"Failed to get cartography schema for scan {scan_id}: {e}" + ) + return {"error": f"Failed to get cartography schema: {str(e)}"} diff --git a/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py b/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py index a6a2ff77ee..042232e6a5 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/resources.py @@ -8,6 +8,7 @@ from typing import Any from prowler_mcp_server.prowler_app.models.resources import ( DetailedResource, + ResourceEventsResponse, ResourcesListResponse, ResourcesMetadataResponse, ) @@ -342,3 +343,62 @@ class ResourcesTools(BaseTool): report = "\n".join(report_lines) return {"report": report} + + async def get_resource_events( + self, + resource_id: str = Field( + description="Prowler's internal UUID (v4) for the resource. Use `prowler_app_list_resources` to find the right ID, or get it from a finding's resource relationship via `prowler_app_get_finding_details`." + ), + lookback_days: int = Field( + default=90, + ge=1, + le=90, + description="How many days back to search for events. Range: 1-90. Default: 90.", + ), + page_size: int = Field( + default=50, + ge=1, + le=50, + description="Number of events to return. Range: 1-50. Default: 50.", + ), + include_read_events: bool = Field( + default=False, + description="Include read-only API calls (e.g., Describe*, Get*, List*). Default: false (write/modify events only).", + ), + ) -> dict[str, Any]: + """Get the timeline of cloud API actions performed on a specific resource. + + IMPORTANT: Currently only available for AWS resources. Uses CloudTrail to retrieve + the modification history of a resource, showing who did what and when. + + Each event includes: + - What happened: event_name (e.g., PutBucketPolicy), event_source (e.g., s3.amazonaws.com) + - Who did it: actor, actor_type, actor_uid + - From where: source_ip_address, user_agent + - What changed: request_data, response_data (full API payloads) + - Errors: error_code, error_message (if the action failed) + + Use cases: + - Investigating security incidents (who modified this resource?) + - Change tracking and audit trails + - Understanding resource configuration drift + - Identifying unauthorized or unexpected modifications + + Workflows: + 1. Resource browsing: prowler_app_list_resources → find resource → this tool for event history + 2. Incident investigation: prowler_app_get_finding_details → get resource ID from finding → this tool to identify who caused the issue, what they changed, and when + """ + params = { + "lookback_days": lookback_days, + "page[size]": page_size, + "include_read_events": include_read_events, + } + + clean_params = self.api_client.build_filter_params(params) + + api_response = await self.api_client.get( + f"/resources/{resource_id}/events", params=clean_params + ) + events_response = ResourceEventsResponse.from_api_response(api_response) + + return events_response.model_dump() diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py index 43a7e59a42..4b6d0e77f5 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/api_client.py @@ -4,11 +4,15 @@ import asyncio from datetime import datetime, timedelta from enum import Enum from typing import Any, Dict +from urllib.parse import urlparse import httpx +from prowler_mcp_server import __version__ from prowler_mcp_server.lib.logger import logger from prowler_mcp_server.prowler_app.utils.auth import ProwlerAppAuth +ALLOWED_EXTERNAL_DOMAINS: frozenset[str] = frozenset({"raw.githubusercontent.com"}) + class HTTPMethod(str, Enum): """HTTP methods enum.""" @@ -187,6 +191,47 @@ class ProwlerAPIClient(metaclass=SingletonMeta): """ return await self._make_request(HTTPMethod.DELETE, path, params=params) + async def fetch_external_url(self, url: str) -> str: + """Fetch content from an allowed external URL (unauthenticated). + + Uses the existing singleton httpx client with a domain allowlist + to prevent SSRF attacks. + + Args: + url: The external URL to fetch content from + + Returns: + Raw text content from the URL + + Raises: + ValueError: If the URL domain is not in the allowlist + Exception: If the HTTP request fails + """ + parsed = urlparse(url) + if parsed.scheme != "https": + raise ValueError(f"Only HTTPS URLs are allowed, got '{parsed.scheme}'") + if parsed.hostname not in ALLOWED_EXTERNAL_DOMAINS: + raise ValueError( + f"Domain '{parsed.hostname}' is not allowed. " + f"Allowed domains: {', '.join(sorted(ALLOWED_EXTERNAL_DOMAINS))}" + ) + + try: + response = await self.client.get( + url, + headers={"User-Agent": f"prowler-mcp-server/{__version__}"}, + ) + response.raise_for_status() + return response.text + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error fetching external URL {url}: {e}") + raise Exception( + f"Failed to fetch external URL: {e.response.status_code}" + ) from e + except Exception as e: + logger.error(f"Error fetching external URL {url}: {e}") + raise + async def poll_task_until_complete( self, task_id: str, diff --git a/mcp_server/pyproject.toml b/mcp_server/pyproject.toml index 2cfb14e671..4ea4a9859e 100644 --- a/mcp_server/pyproject.toml +++ b/mcp_server/pyproject.toml @@ -11,7 +11,7 @@ description = "MCP server for Prowler ecosystem" name = "prowler-mcp" readme = "README.md" requires-python = ">=3.12" -version = "0.4.0" +version = "0.5.0" [project.scripts] prowler-mcp = "prowler_mcp_server.main:main" diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock index f98c2eaa57..ca1d9482be 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -36,14 +36,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.5" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, ] [[package]] @@ -717,7 +717,7 @@ wheels = [ [[package]] name = "prowler-mcp" -version = "0.3.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "fastmcp" }, diff --git a/poetry.lock b/poetry.lock index 73ac5542c0..1206608902 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "about-time" @@ -605,21 +605,21 @@ requests = ">=2.21.0,<3.0.0" [[package]] name = "alibabacloud-tea-openapi" -version = "0.4.1" +version = "0.4.4" description = "Alibaba Cloud openapi SDK Library for Python" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "alibabacloud_tea_openapi-0.4.1-py3-none-any.whl", hash = "sha256:e46bfa3ca34086d2c357d217a0b7284ecbd4b3bab5c88e075e73aec637b0e4a0"}, - {file = "alibabacloud_tea_openapi-0.4.1.tar.gz", hash = "sha256:2384b090870fdb089c3c40f3fb8cf0145b8c7d6c14abbac521f86a01abb5edaf"}, + {file = "alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f"}, + {file = "alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce"}, ] [package.dependencies] alibabacloud-credentials = ">=1.0.2,<2.0.0" alibabacloud-gateway-spi = ">=0.0.2,<1.0.0" alibabacloud-tea-util = ">=0.3.13,<1.0.0" -cryptography = ">=3.0.0,<45.0.0" +cryptography = {version = ">=3.0.0,<47.0.0", markers = "python_version >= \"3.8\""} darabonba-core = ">=1.0.3,<2.0.0" [[package]] @@ -808,7 +808,7 @@ description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "python_version <= \"3.10\"" +markers = "python_version == \"3.10\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -836,14 +836,14 @@ tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" a [[package]] name = "authlib" -version = "1.6.5" +version = "1.6.9" description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a"}, - {file = "authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b"}, + {file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"}, + {file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"}, ] [package.dependencies] @@ -1544,10 +1544,7 @@ files = [ [package.dependencies] jmespath = ">=0.7.1,<2.0.0" python-dateutil = ">=2.1,<3.0.0" -urllib3 = [ - {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}, - {version = ">=1.25.4,<1.27", markers = "python_version < \"3.10\""}, -] +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} [package.extras] crt = ["awscrt (==0.27.6)"] @@ -1827,22 +1824,6 @@ files = [ {file = "circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084"}, ] -[[package]] -name = "click" -version = "8.1.8" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version < \"3.10\"" -files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "click" version = "8.2.1" @@ -1850,7 +1831,6 @@ description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, @@ -1908,7 +1888,6 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} [[package]] name = "contextlib2" @@ -2003,62 +1982,75 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "44.0.3" +version = "46.0.6" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.7" +python-versions = "!=3.9.0,!=3.9.1,>=3.8" groups = ["main", "dev"] files = [ - {file = "cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01"}, - {file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d"}, - {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904"}, - {file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44"}, - {file = "cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d"}, - {file = "cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d"}, - {file = "cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c"}, - {file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f"}, - {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5"}, - {file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b"}, - {file = "cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028"}, - {file = "cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06"}, - {file = "cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5"}, - {file = "cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c"}, - {file = "cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053"}, + {file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"}, + {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"}, + {file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"}, + {file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"}, + {file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"}, + {file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"}, + {file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"}, + {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"}, + {file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"}, + {file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"}, + {file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"}, + {file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"}, + {file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"}, + {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"}, + {file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"}, + {file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"}, + {file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"}, + {file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"}, + {file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"}, + {file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"}, ] [package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] -pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox[uv] (>=2024.4.15)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -2152,24 +2144,6 @@ files = [ {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] -[[package]] -name = "deprecated" -version = "1.2.18" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -groups = ["main"] -files = [ - {file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"}, - {file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"}, -] - -[package.dependencies] -wrapt = ">=1.10,<2" - -[package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] - [[package]] name = "detect-secrets" version = "1.5.0" @@ -2274,33 +2248,6 @@ docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] ssh = ["paramiko (>=2.4.3)"] websockets = ["websocket-client (>=1.3.0)"] -[[package]] -name = "dogpile-cache" -version = "1.4.1" -description = "A caching front-end based on the Dogpile lock." -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version < \"3.10\"" -files = [ - {file = "dogpile_cache-1.4.1-py3-none-any.whl", hash = "sha256:99130ce990800c8d89c26a5a8d9923cbe1b78c8a9972c2aaa0abf3d2ef2984ad"}, - {file = "dogpile_cache-1.4.1.tar.gz", hash = "sha256:e25c60e677a5e28ff86124765fbf18c53257bcd7830749cd5ba350ace2a12989"}, -] - -[package.dependencies] -decorator = ">=4.0.0" -stevedore = ">=3.0.0" -typing_extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -bmemcached = ["python-binary-memcached"] -memcached = ["python-memcached"] -pifpaf = ["pifpaf (>=3.2.0)"] -pylibmc = ["pylibmc"] -pymemcache = ["pymemcache"] -redis = ["redis"] -valkey = ["valkey"] - [[package]] name = "dogpile-cache" version = "1.5.0" @@ -2308,7 +2255,6 @@ description = "A caching front-end based on the Dogpile lock." optional = false python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\"" files = [ {file = "dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d"}, {file = "dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec"}, @@ -2433,7 +2379,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "dev"] -markers = "python_version <= \"3.10\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -2460,19 +2406,6 @@ files = [ [package.extras] testing = ["hatch", "pre-commit", "pytest", "tox"] -[[package]] -name = "filelock" -version = "3.19.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -markers = "python_version < \"3.10\"" -files = [ - {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, - {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, -] - [[package]] name = "filelock" version = "3.20.3" @@ -2480,7 +2413,6 @@ description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["main", "dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, @@ -2518,7 +2450,6 @@ files = [ [package.dependencies] blinker = ">=1.9.0" click = ">=8.1.3" -importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} itsdangerous = ">=2.2.0" jinja2 = ">=3.1.2" markupsafe = ">=2.1.1" @@ -2790,9 +2721,6 @@ files = [ {file = "graphql_core-3.2.6.tar.gz", hash = "sha256:c08eec22f9e40f0bd61d805907e3b3b1b9a320bc606e23dc145eebca07c8fbab"}, ] -[package.dependencies] -typing-extensions = {version = ">=4,<5", markers = "python_version < \"3.10\""} - [[package]] name = "h11" version = "0.16.0" @@ -2956,12 +2884,11 @@ version = "8.7.0" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, ] -markers = {dev = "python_version < \"3.10\""} [package.dependencies] zipp = ">=3.20" @@ -3156,7 +3083,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -3197,34 +3124,6 @@ files = [ [package.dependencies] referencing = ">=0.31.0" -[[package]] -name = "keystoneauth1" -version = "5.11.1" -description = "Authentication Library for OpenStack Identity" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version < \"3.10\"" -files = [ - {file = "keystoneauth1-5.11.1-py3-none-any.whl", hash = "sha256:4525adf03b6e591f4b9b8a72c3b14f6510a04816dd5a7aca6ebaa6dfc90b69e6"}, - {file = "keystoneauth1-5.11.1.tar.gz", hash = "sha256:806f12c49b7f4b2cad3f5a460f7bdd81e4247c81b6042596a7fea8575f6591f3"}, -] - -[package.dependencies] -iso8601 = ">=2.0.0" -os-service-types = ">=1.2.0" -pbr = ">=2.0.0" -requests = ">=2.14.2" -stevedore = ">=1.20.0" -typing-extensions = ">=4.12" - -[package.extras] -betamax = ["PyYAML (>=3.13)", "betamax (>=0.7.0)", "fixtures (>=3.0.0)"] -kerberos = ["requests-kerberos (>=0.8.0)"] -oauth1 = ["oauthlib (>=0.6.2)"] -saml2 = ["lxml (>=4.2.0)"] -test = ["PyYAML (>=3.12)", "bandit (>=1.7.6,<1.8.0)", "betamax (>=0.7.0)", "coverage (>=4.0)", "fixtures (>=3.0.0)", "flake8-docstrings (>=1.7.0,<1.8.0)", "flake8-import-order (>=0.18.2,<0.19.0)", "hacking (>=6.1.0,<6.2.0)", "lxml (>=4.2.0)", "oauthlib (>=0.6.2)", "oslo.config (>=5.2.0)", "oslo.utils (>=3.33.0)", "oslotest (>=3.2.0)", "requests-kerberos (>=0.8.0)", "requests-mock (>=1.2.0)", "stestr (>=1.0.0)", "testresources (>=2.0.0)", "testtools (>=2.2.0)"] - [[package]] name = "keystoneauth1" version = "5.13.0" @@ -3232,7 +3131,6 @@ description = "Authentication Library for OpenStack Identity" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\"" files = [ {file = "keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41"}, {file = "keystoneauth1-5.13.0.tar.gz", hash = "sha256:57c9ca407207899b50d8ff1ca8abb4a4e7427461bfc1877eb8519c3989ce63ec"}, @@ -3265,7 +3163,7 @@ files = [ ] [package.dependencies] -certifi = ">=14.5.14" +certifi = ">=14.05.14" durationpy = ">=0.7" google-auth = ">=1.0.1" oauthlib = ">=3.2.2" @@ -3378,21 +3276,18 @@ tests = ["psutil", "pytest (!=3.3.0)", "pytest-cov"] [[package]] name = "markdown" -version = "3.9" +version = "3.10.2" description = "Python implementation of John Gruber's Markdown." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280"}, - {file = "markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a"}, + {file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"}, + {file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"}, ] -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - [package.extras] -docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"] testing = ["coverage", "pyyaml"] [[package]] @@ -4036,26 +3931,6 @@ files = [ {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, ] -[[package]] -name = "networkx" -version = "3.2.1" -description = "Python package for creating and manipulating graphs and networks" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version < \"3.10\"" -files = [ - {file = "networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2"}, - {file = "networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6"}, -] - -[package.extras] -default = ["matplotlib (>=3.5)", "numpy (>=1.22)", "pandas (>=1.4)", "scipy (>=1.9,!=1.11.0,!=1.11.1)"] -developer = ["changelist (==0.4)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] -doc = ["nb2plots (>=0.7)", "nbconvert (<7.9)", "numpydoc (>=1.6)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.14)", "sphinx (>=7)", "sphinx-gallery (>=0.14)", "texext (>=0.6.7)"] -extra = ["lxml (>=4.6)", "pydot (>=1.4.2)", "pygraphviz (>=1.11)", "sympy (>=1.10)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] - [[package]] name = "networkx" version = "3.4.2" @@ -4101,14 +3976,14 @@ test-extras = ["pytest-mpl", "pytest-randomly"] [[package]] name = "nltk" -version = "3.9.2" +version = "3.9.4" description = "Natural Language Toolkit" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"}, - {file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"}, + {file = "nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f"}, + {file = "nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0"}, ] [package.dependencies] @@ -4211,23 +4086,24 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] [[package]] name = "oci" -version = "2.160.3" +version = "2.169.0" description = "Oracle Cloud Infrastructure Python SDK" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "oci-2.160.3-py3-none-any.whl", hash = "sha256:858bff3e697098bdda44833d2476bfb4632126f0182178e7dbde4dbd156d71f0"}, - {file = "oci-2.160.3.tar.gz", hash = "sha256:57514889be3b713a8385d86e3ba8a33cf46e3563c2a7e29a93027fb30b8a2537"}, + {file = "oci-2.169.0-py3-none-any.whl", hash = "sha256:c71bb5143f307791082b3e33cc1545c2490a518cfed85ab1948ef5107c36d30b"}, + {file = "oci-2.169.0.tar.gz", hash = "sha256:f3c5fff00b01783b5325ea7b13bf140053ec1e9f41da20bfb9c8a349ee7662fa"}, ] [package.dependencies] certifi = "*" circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""} -cryptography = ">=3.2.1,<46.0.0" -pyOpenSSL = ">=17.5.0,<25.0.0" +cryptography = ">=3.2.1,<47.0.0" +pyOpenSSL = ">=17.5.0,<27.0.0" python-dateutil = ">=2.5.3,<3.0.0" pytz = ">=2016.10" +urllib3 = {version = ">=2.6.3", markers = "python_version >= \"3.10.0\""} [package.extras] adk = ["docstring-parser (>=0.16) ; python_version >= \"3.10\" and python_version < \"4\"", "mcp (>=1.6.0) ; python_version >= \"3.10\" and python_version < \"4\"", "pydantic (>=2.10.6) ; python_version >= \"3.10\" and python_version < \"4\"", "rich (>=13.9.4) ; python_version >= \"3.10\" and python_version < \"4\""] @@ -4343,22 +4219,6 @@ files = [ opentelemetry-api = "1.35.0" typing-extensions = ">=4.5.0" -[[package]] -name = "os-service-types" -version = "1.7.0" -description = "Python library for consuming OpenStack sevice-types-authority data" -optional = false -python-versions = "*" -groups = ["main"] -markers = "python_version < \"3.10\"" -files = [ - {file = "os-service-types-1.7.0.tar.gz", hash = "sha256:31800299a82239363995b91f1ebf9106ac7758542a1e4ef6dc737a5932878c6c"}, - {file = "os_service_types-1.7.0-py2.py3-none-any.whl", hash = "sha256:0505c72205690910077fb72b88f2a1f07533c8d39f2fe75b29583481764965d6"}, -] - -[package.dependencies] -pbr = ">=2.0.0,<2.1.0 || >2.1.0" - [[package]] name = "os-service-types" version = "1.8.2" @@ -4366,7 +4226,6 @@ description = "Python library for consuming OpenStack sevice-types-authority dat optional = false python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\"" files = [ {file = "os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e"}, {file = "os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575"}, @@ -5051,22 +4910,21 @@ files = [ [[package]] name = "pygithub" -version = "2.5.0" +version = "2.8.0" description = "Use the full Github API v3" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"}, - {file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"}, + {file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"}, + {file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"}, ] [package.dependencies] -Deprecated = "*" pyjwt = {version = ">=2.4.0", extras = ["crypto"]} pynacl = ">=1.4.0" requests = ">=2.14.0" -typing-extensions = ">=4.0.0" +typing-extensions = ">=4.5.0" urllib3 = ">=1.26.0" [[package]] @@ -5118,7 +4976,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.3.8,<=3.4.0.dev0" +astroid = ">=3.3.8,<=3.4.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, @@ -5130,7 +4988,6 @@ mccabe = ">=0.6,<0.8" platformdirs = ">=2.2.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} tomlkit = ">=0.10.1" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] spelling = ["pyenchant (>=3.2,<4.0)"] @@ -5180,18 +5037,19 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", " [[package]] name = "pyopenssl" -version = "24.3.0" +version = "26.0.0" description = "Python wrapper module around the OpenSSL library" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"}, - {file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"}, + {file = "pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81"}, + {file = "pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc"}, ] [package.dependencies] -cryptography = ">=41.0.5,<45" +cryptography = ">=46.0.0,<47" +typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} [package.extras] docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] @@ -5286,7 +5144,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} pytest = "*" [[package]] @@ -5965,10 +5822,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a0" +botocore = ">=1.37.4,<2.0a.0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "safety" @@ -6237,7 +6094,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version <= \"3.10\"" +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -6394,24 +6251,6 @@ files = [ {file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"}, ] -[[package]] -name = "urllib3" -version = "1.26.20" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -groups = ["main", "dev"] -markers = "python_version < \"3.10\"" -files = [ - {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, - {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, -] - -[package.extras] -brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and platform_python_implementation == \"CPython\"", "brotli (>=1.0.9) ; python_version >= \"3\" and platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; (os_name != \"nt\" or python_version >= \"3\") and platform_python_implementation != \"CPython\"", "brotlipy (>=0.6.0) ; os_name == \"nt\" and python_version < \"3\""] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] - [[package]] name = "urllib3" version = "2.6.3" @@ -6419,7 +6258,6 @@ description = "HTTP library with thread-safe connection pooling, file post, and optional = false python-versions = ">=3.9" groups = ["main", "dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, @@ -6520,7 +6358,7 @@ version = "1.17.2" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, @@ -6752,12 +6590,11 @@ version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main"] files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] -markers = {dev = "python_version < \"3.10\""} [package.extras] check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] @@ -6905,5 +6742,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = ">3.9.1,<3.13" -content-hash = "386f6cf2bed49290cc4661aa2093ceb018aa6cdaf6864bdfab36f6c2c50e241e" +python-versions = ">=3.10,<3.13" +content-hash = "91739ee5e383337160f9f08b76944ab4e8629c94084c8a9d115246862557f7c5" diff --git a/prowler/AGENTS.md b/prowler/AGENTS.md index 4667ba8a28..b1d83fa200 100644 --- a/prowler/AGENTS.md +++ b/prowler/AGENTS.md @@ -81,7 +81,7 @@ class {check_name}(Check): ## TECH STACK -Python 3.9+ | Poetry 2+ | pytest | moto (AWS mocking) | Pre-commit hooks (black, flake8, pylint, bandit) +Python 3.10+ | Poetry 2+ | pytest | moto (AWS mocking) | Pre-commit hooks (black, flake8, pylint, bandit) --- diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 9240e90002..fc5c695f96 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,10 +2,152 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.23.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `apikeys_api_restricted_with_gemini_api` and `gemini_api_disabled` checks for GCP provider [(#10280)](https://github.com/prowler-cloud/prowler/pull/10280) +- `cloudfront_distributions_logging_enabled` detects Standard Logging v2 via CloudWatch Log Delivery [(#10090)](https://github.com/prowler-cloud/prowler/pull/10090) +- `glue_etl_jobs_no_secrets_in_arguments` check for plaintext secrets in AWS Glue ETL job arguments [(#10368)](https://github.com/prowler-cloud/prowler/pull/10368) +- `awslambda_function_no_dead_letter_queue`, `awslambda_function_using_cross_account_layers`, and `awslambda_function_env_vars_not_encrypted_with_cmk` checks for AWS Lambda [(#10381)](https://github.com/prowler-cloud/prowler/pull/10381) +- `entra_conditional_access_policy_mdm_compliant_device_required` check for M365 provider [(#10220)](https://github.com/prowler-cloud/prowler/pull/10220) +- `ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip` check for AWS provider using `ipaddress.is_global` for accurate public IP detection [(#10335)](https://github.com/prowler-cloud/prowler/pull/10335) +- `entra_conditional_access_policy_block_o365_elevated_insider_risk` check for M365 provider [(#10232)](https://github.com/prowler-cloud/prowler/pull/10232) +- `--resource-group` and `--list-resource-groups` CLI flags to filter checks by resource group across all providers [(#10479)](https://github.com/prowler-cloud/prowler/pull/10479) +- CISA SCuBA Google Workspace Baselines compliance [(#10466)](https://github.com/prowler-cloud/prowler/pull/10466) +- CIS Google Workspace Foundations Benchmark v1.3.0 compliance [(#10462)](https://github.com/prowler-cloud/prowler/pull/10462) +- `entra_conditional_access_policy_device_registration_mfa_required` check and `entra_intune_enrollment_sign_in_frequency_every_time` enhancement for M365 provider [(#10222)](https://github.com/prowler-cloud/prowler/pull/10222) +- `entra_conditional_access_policy_block_elevated_insider_risk` check for M365 provider [(#10234)](https://github.com/prowler-cloud/prowler/pull/10234) +- `Vercel` provider support with 30 checks [(#10189)](https://github.com/prowler-cloud/prowler/pull/10189) + +### 🔄 Changed + +- Added `internet-exposed` category to 13 AWS checks (CloudFront, CodeArtifact, EC2, EFS, RDS, SageMaker, Shield, VPC) [(#10502)](https://github.com/prowler-cloud/prowler/pull/10502) +- Minimum Python version from 3.9 to 3.10 and updated classifiers to reflect supported versions (3.10, 3.11, 3.12) [(#10464)](https://github.com/prowler-cloud/prowler/pull/10464) + +### 🐞 Fixed + +- OCI mutelist support: pass `tenancy_id` to `is_finding_muted` and update `oraclecloud_mutelist_example.yaml` to use `Accounts` key [(#10565)](https://github.com/prowler-cloud/prowler/issues/10565) +- `return` statements in `finally` blocks replaced across IAM, Organizations, GCP provider, and custom checks metadata to stop silently swallowing exceptions [(#10102)](https://github.com/prowler-cloud/prowler/pull/10102) +- `JiraConnection` now includes issue types per project fetched during `test_connection`, fixing `JiraInvalidIssueTypeError` on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) +- `--list-checks` and `--list-checks-json` now include `threat-detection` category checks in their output [(#10578)](https://github.com/prowler-cloud/prowler/pull/10578) +- Missing `__init__.py` in `codebuild_project_uses_allowed_github_organizations` check preventing discovery by `--list-checks` [(#10584)](https://github.com/prowler-cloud/prowler/pull/10584) +- Azure Key Vault checks emitting incorrect findings for keys, secrets, and vault logging [(#10332)](https://github.com/prowler-cloud/prowler/pull/10332) + +### 🔐 Security + +- Sensitive CLI flag values (tokens, keys, passwords) in HTML output "Parameters used" field now redacted to prevent credential leaks [(#10518)](https://github.com/prowler-cloud/prowler/pull/10518) +- `authlib` bumped from 1.6.5 to 1.6.9 to fix CVE-2026-28802 (JWT `alg: none` validation bypass) [(#10579)](https://github.com/prowler-cloud/prowler/pull/10579) +- `cryptography` bumped from 44.0.3 to 46.0.6 ([CVE-2026-26007](https://github.com/pyca/cryptography/security/advisories/GHSA-r6ph-v2qm-q3c2), [CVE-2026-34073](https://github.com/pyca/cryptography/security/advisories/GHSA-m959-cc7f-wv43)), `oci` to 2.169.0, and `alibabacloud-tea-openapi` to 0.4.4 [(#10535)](https://github.com/prowler-cloud/prowler/pull/10535) + +--- + +## [5.22.1] (Prowler UNRELEASED) + +### 🐞 Fixed + +- AWS global services (CloudFront, Route53, Shield, FMS) now use the partition's global region instead of the profile's default region [(#10458)](https://github.com/prowler-cloud/prowler/issues/10458) +- Oracle Cloud `events_rule_idp_group_mapping_changes` now recognizes the CIS 3.1 `add/remove` event names to avoid false positives [(#10416)](https://github.com/prowler-cloud/prowler/pull/10416) +- Oracle Cloud password policy checks now exclude immutable system-managed policies (`SimplePasswordPolicy`, `StandardPasswordPolicy`) to avoid false positives [(#10453)](https://github.com/prowler-cloud/prowler/pull/10453) +- Oracle Cloud `kms_key_rotation_enabled` now checks current key version age to avoid false positives on vaults without auto-rotation support [(#10450)](https://github.com/prowler-cloud/prowler/pull/10450) +- Oracle Cloud patch for filestorage, blockstorage, kms, and compute services in OCI to allow for region scanning outside home [(#10455)](https://github.com/prowler-cloud/prowler/pull/10472) +- Oracle cloud provider now supports multi-region filtering [(#10435)](https://github.com/prowler-cloud/prowler/pull/10473) +- `prowler image --registry` failing with `ImageNoImagesProvidedError` due to registry arguments not being forwarded to `ImageProvider` in `init_global_provider` [(#10457)](https://github.com/prowler-cloud/prowler/issues/10457) +- Oracle Cloud multi-region support for identity client configuration in blockstorage, identity, and filestorage services [(#10519)](https://github.com/prowler-cloud/prowler/pull/10520) + +--- + +## [5.22.0] (Prowler v5.22.0) + +### 🐞 Fixed + +- Azure MySQL flexible server checks now compare configuration values case-insensitively to avoid false negatives when Azure returns lowercase values [(#10396)](https://github.com/prowler-cloud/prowler/pull/10396) +- Azure `vm_backup_enabled` and `vm_sufficient_daily_backup_retention_period` checks now compare VM names case-insensitively to avoid false negatives when Azure stores backup item names in a different case [(#10395)](https://github.com/prowler-cloud/prowler/pull/10395) +- `entra_non_privileged_user_has_mfa` skips disabled users to avoid false positives [(#10426)](https://github.com/prowler-cloud/prowler/pull/10426) + +--- + +## [5.21.0] (Prowler v5.21.0) + +### 🚀 Added + +- `misconfig` scanner as default for Image provider scans [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167) +- `entra_conditional_access_policy_device_code_flow_blocked` check for M365 provider [(#10218)](https://github.com/prowler-cloud/prowler/pull/10218) +- RBI compliance for the Azure provider [(#10339)](https://github.com/prowler-cloud/prowler/pull/10339) +-`entra_conditional_access_policy_require_mfa_for_admin_portals` check for Azure provider and update CIS compliance [(#10330)](https://github.com/prowler-cloud/prowler/pull/10330) +- CheckMetadata Pydantic validators [(#8583)](https://github.com/prowler-cloud/prowler/pull/8583) +- `organization_repository_deletion_limited` check for GitHub provider [(#10185)](https://github.com/prowler-cloud/prowler/pull/10185) +- SecNumCloud 3.2 for the GCP provider [(#10364)](https://github.com/prowler-cloud/prowler/pull/10364) +- SecNumCloud 3.2 for the Azure provider [(#10358)](https://github.com/prowler-cloud/prowler/pull/10358) +- SecNumCloud 3.2 for the Alibaba Cloud provider [(#10370)](https://github.com/prowler-cloud/prowler/pull/10370) +- SecNumCloud 3.2 for the Oracle Cloud provider [(#10371)](https://github.com/prowler-cloud/prowler/pull/10371) + +### 🔄 Changed + +- Bump `pygithub` from 2.5.0 to 2.8.0 to use native Organization properties +- Update M365 SharePoint service metadata to new format [(#9684)](https://github.com/prowler-cloud/prowler/pull/9684) +- Update M365 Exchange service metadata to new format [(#9683)](https://github.com/prowler-cloud/prowler/pull/9683) +- Update M365 Teams service metadata to new format [(#9685)](https://github.com/prowler-cloud/prowler/pull/9685) +- Update M365 Entra ID service metadata to new format [(#9682)](https://github.com/prowler-cloud/prowler/pull/9682) +- Update ResourceType and Categories for Azure Entra ID service metadata [(#10334)](https://github.com/prowler-cloud/prowler/pull/10334) +- Update OCI Regions to include US DoD regions [(#10375)](https://github.com/prowler-cloud/prowler/pull/10376) + +### 🐞 Fixed + +- Route53 dangling IP check false positive when using `--region` flag [(#9952)](https://github.com/prowler-cloud/prowler/pull/9952) +- RBI compliance framework support on Prowler Dashboard for the Azure provider [(#10360)](https://github.com/prowler-cloud/prowler/pull/10360) +- CheckMetadata strict validators rejecting valid external tool provider data (image, iac, llm) [(#10363)](https://github.com/prowler-cloud/prowler/pull/10363) + +### 🔐 Security + +- Bump `multipart` to 1.3.1 to fix [GHSA-p2m9-wcp5-6qw3](https://github.com/defnull/multipart/security/advisories/GHSA-p2m9-wcp5-6qw3) [(#10331)](https://github.com/prowler-cloud/prowler/pull/10331) + +--- + +## [5.20.0] (Prowler v5.20.0) + +### 🚀 Added + +- `entra_conditional_access_policy_approved_client_app_required_for_mobile` check for M365 provider [(#10216)](https://github.com/prowler-cloud/prowler/pull/10216) +- `entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required` check for M365 provider [(#10197)](https://github.com/prowler-cloud/prowler/pull/10197) +- `trusted_ips` configurable option for `opensearch_service_domains_not_publicly_accessible` check to reduce false positives on IP-restricted policies [(#8631)](https://github.com/prowler-cloud/prowler/pull/8631) +- `guardduty_delegated_admin_enabled_all_regions` check for AWS provider [(#9867)](https://github.com/prowler-cloud/prowler/pull/9867) +- OpenStack object storage service with 7 checks [(#10258)](https://github.com/prowler-cloud/prowler/pull/10258) +- AWS Organizations OU metadata (OU ID, OU path) in ASFF, OCSF and CSV outputs [(#10283)](https://github.com/prowler-cloud/prowler/pull/10283) + +### 🔄 Changed + +- Update Kubernetes API server checks metadata to new format [(#9674)](https://github.com/prowler-cloud/prowler/pull/9674) +- Update Kubernetes Controller Manager service metadata to new format [(#9675)](https://github.com/prowler-cloud/prowler/pull/9675) +- Update Kubernetes Core service metadata to new format [(#9676)](https://github.com/prowler-cloud/prowler/pull/9676) +- Update Kubernetes Kubelet service metadata to new format [(#9677)](https://github.com/prowler-cloud/prowler/pull/9677) +- Update Kubernetes RBAC service metadata to new format [(#9678)](https://github.com/prowler-cloud/prowler/pull/9678) +- Update Kubernetes Scheduler service metadata to new format [(#9679)](https://github.com/prowler-cloud/prowler/pull/9679) +- Update MongoDB Atlas Organizations service metadata to new format [(#9658)](https://github.com/prowler-cloud/prowler/pull/9658) +- Update MongoDB Atlas clusters service metadata to new format [(#9657)](https://github.com/prowler-cloud/prowler/pull/9657) +- Update GitHub Repository service metadata to new format [(#9659)](https://github.com/prowler-cloud/prowler/pull/9659) +- Update GitHub Organization service metadata to new format [(#10273)](https://github.com/prowler-cloud/prowler/pull/10273) +- Update Oracle Cloud Compute Engine service metadata to new format [(#9371)](https://github.com/prowler-cloud/prowler/pull/9371) +- Update Oracle Cloud Database service metadata to new format [(#9372)](https://github.com/prowler-cloud/prowler/pull/9372) +- Update Oracle Cloud File Storage service metadata to new format [(#9374)](https://github.com/prowler-cloud/prowler/pull/9374) +- Update Oracle Cloud Integration service metadata to new format [(#9376)](https://github.com/prowler-cloud/prowler/pull/9376) +- Update Oracle Cloud KMS service metadata to new format [(#9377)](https://github.com/prowler-cloud/prowler/pull/9377) +- Update Oracle Cloud Network service metadata to new format [(#9378)](https://github.com/prowler-cloud/prowler/pull/9378) +- Update Oracle Cloud Object Storage service metadata to new format [(#9379)](https://github.com/prowler-cloud/prowler/pull/9379) +- Update Oracle Cloud Events service metadata to new format [(#9373)](https://github.com/prowler-cloud/prowler/pull/9373) +- Update Oracle Cloud Identity service metadata to new format [(#9375)](https://github.com/prowler-cloud/prowler/pull/9375) +- Update Alibaba Cloud services metadata to new format [(#10289)](https://github.com/prowler-cloud/prowler/pull/10289) +- Update M365 Admin Center service metadata to new format [(#9680)](https://github.com/prowler-cloud/prowler/pull/9680) +- Update M365 Defender service metadata to new format [(#9681)](https://github.com/prowler-cloud/prowler/pull/9681) +- Update M365 Purview service metadata to new format [(#9092)](https://github.com/prowler-cloud/prowler/pull/9092) + +--- + ## [5.19.0] (Prowler v5.19.0) ### 🚀 Added +- `entra_authentication_method_sms_voice_disabled` check for M365 provider [(#10212)](https://github.com/prowler-cloud/prowler/pull/10212) - `Google Workspace` provider support with Directory service including 1 security check [(#10022)](https://github.com/prowler-cloud/prowler/pull/10022) - `entra_conditional_access_policy_app_enforced_restrictions` check for M365 provider [(#10058)](https://github.com/prowler-cloud/prowler/pull/10058) - `entra_app_registration_no_unused_privileged_permissions` check for M365 provider [(#10080)](https://github.com/prowler-cloud/prowler/pull/10080) @@ -33,12 +175,15 @@ All notable changes to the **Prowler SDK** are documented in this file. - CIS 6.0 for the AWS provider [(#10127)](https://github.com/prowler-cloud/prowler/pull/10127) - `entra_conditional_access_policy_require_mfa_for_management_api` check for M365 provider [(#10150)](https://github.com/prowler-cloud/prowler/pull/10150) - OpenStack provider multiple regions support [(#10135)](https://github.com/prowler-cloud/prowler/pull/10135) +- `entra_break_glass_account_fido2_security_key_registered` check for M365 provider [(#10213)](https://github.com/prowler-cloud/prowler/pull/10213) - `entra_default_app_management_policy_enabled` check for M365 provider [(#9898)](https://github.com/prowler-cloud/prowler/pull/9898) - OpenStack networking service with 6 security checks [(#9970)](https://github.com/prowler-cloud/prowler/pull/9970) - OpenStack block storage service with 7 security checks [(#10120)](https://github.com/prowler-cloud/prowler/pull/10120) - OpenStack compute service with 7 security checks [(#9944)](https://github.com/prowler-cloud/prowler/pull/9944) - OpenStack image service with 6 security checks [(#10096)](https://github.com/prowler-cloud/prowler/pull/10096) -- IaC `--provider-uid` flag to specify the provider UID for the IaC provider [(#10233)](https://github.com/prowler-cloud/prowler/pull/10233) +- `--provider-uid` CLI flag for IaC provider, used as `cloud.account.uid` in OCSF output and required with `--export-ocsf` [(#10233)](https://github.com/prowler-cloud/prowler/pull/10233) +- `unmapped.provider_uid` field in OCSF output to match CLI scan results with API provider entities during ingestion [(#10231)](https://github.com/prowler-cloud/prowler/pull/10231) +- `unmapped.provider` field in OCSF output for provider name availability in non-cloud providers like Kubernetes [(#10240)](https://github.com/prowler-cloud/prowler/pull/10240) - `entra_conditional_access_policy_unknown_device_blocked` check for M365 provider [(#10235)](https://github.com/prowler-cloud/prowler/pull/10235) ### 🔄 Changed @@ -64,10 +209,12 @@ All notable changes to the **Prowler SDK** are documented in this file. - Cloudflare provider credential validation with specific exceptions [(#9910)](https://github.com/prowler-cloud/prowler/pull/9910) - Enhance AWS IAM privilege escalation detection with patterns from pathfinding.cloud library [(#9922)](https://github.com/prowler-cloud/prowler/pull/9922) - Bump Trivy from 0.66.0 to 0.69.2 [(#10210)](https://github.com/prowler-cloud/prowler/pull/10210) -- Modify GitHub and M365 account UIDs [(#10226)](https://github.com/prowler-cloud/prowler/pull/10226) +- Standardize GitHub and M365 provider account UIDs for consistent OCSF output [(#10226)](https://github.com/prowler-cloud/prowler/pull/10226) +- Standardize Cloudflare account and resource UIDs to prevent None values in findings [(#10227)](https://github.com/prowler-cloud/prowler/pull/10227) ### 🐞 Fixed +- Google Workspace provider `test_connection()` missing `provider_id` parameter for API integration [(#10247)](https://github.com/prowler-cloud/prowler/pull/10247) - Update AWS checks metadata URLs to replace deprecated Trend Micro CloudOne Conformity (EOL July 2026) with Vision One and remove docs.prowler.com references [(#10068)](https://github.com/prowler-cloud/prowler/pull/10068) - Standardize resource_id values across Azure checks to use actual Azure resource IDs and prevent duplicate resource entries [(#9994)](https://github.com/prowler-cloud/prowler/pull/9994) - VPC endpoint service collection filtering third-party services that caused AccessDenied errors on `DescribeVpcEndpointServicePermissions` [(#10152)](https://github.com/prowler-cloud/prowler/pull/10152) diff --git a/prowler/__main__.py b/prowler/__main__.py index 07e7c19a35..b46a8fa579 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -27,6 +27,7 @@ from prowler.lib.check.check import ( list_categories, list_checks_json, list_fixers, + list_resource_groups, list_services, load_custom_checks_metadata, parse_checks_from_file, @@ -36,6 +37,7 @@ from prowler.lib.check.check import ( print_compliance_frameworks, print_compliance_requirements, print_fixers, + print_resource_groups, print_services, remove_custom_checks_module, run_fixer, @@ -65,7 +67,11 @@ from prowler.lib.outputs.compliance.cis.cis_aws import AWSCIS from prowler.lib.outputs.compliance.cis.cis_azure import AzureCIS from prowler.lib.outputs.compliance.cis.cis_gcp import GCPCIS from prowler.lib.outputs.compliance.cis.cis_github import GithubCIS +from prowler.lib.outputs.compliance.cis.cis_googleworkspace import GoogleWorkspaceCIS from prowler.lib.outputs.compliance.cis.cis_kubernetes import KubernetesCIS +from prowler.lib.outputs.compliance.cisa_scuba.cisa_scuba_googleworkspace import ( + GoogleWorkspaceCISASCuBA, +) from prowler.lib.outputs.compliance.cis.cis_m365 import M365CIS from prowler.lib.outputs.compliance.cis.cis_oraclecloud import OracleCloudCIS from prowler.lib.outputs.compliance.compliance import display_compliance_table @@ -139,6 +145,7 @@ from prowler.providers.mongodbatlas.models import MongoDBAtlasOutputOptions from prowler.providers.nhn.models import NHNOutputOptions from prowler.providers.openstack.models import OpenStackOutputOptions from prowler.providers.oraclecloud.models import OCIOutputOptions +from prowler.providers.vercel.models import VercelOutputOptions def prowler(): @@ -161,6 +168,7 @@ def prowler(): excluded_services = args.excluded_service services = args.service categories = args.category + resource_groups = args.resource_group checks_file = args.checks_file checks_folder = args.checks_folder severities = args.severity @@ -170,6 +178,7 @@ def prowler(): not checks and not services and not categories + and not resource_groups and not excluded_checks and not excluded_services and not severities @@ -215,6 +224,10 @@ def prowler(): print_categories(list_categories(bulk_checks_metadata)) sys.exit() + if args.list_resource_groups: + print_resource_groups(list_resource_groups(bulk_checks_metadata)) + sys.exit() + bulk_compliance_frameworks = {} # Load compliance frameworks logger.debug("Loading compliance frameworks from .json files") @@ -256,7 +269,10 @@ def prowler(): severities=severities, compliance_frameworks=compliance_framework, categories=categories, + resource_groups=resource_groups, provider=provider, + list_checks=getattr(args, "list_checks", False) + or getattr(args, "list_checks_json", False), ) # if --list-checks-json, dump a json file and exit @@ -385,6 +401,10 @@ def prowler(): output_options = OpenStackOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "vercel": + output_options = VercelOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) # Run the quick inventory for the provider if available if hasattr(args, "quick_inventory") and args.quick_inventory: @@ -529,7 +549,7 @@ def prowler(): provider=global_provider, stats=stats ) - if getattr(args, "export_ocsf", False): + if getattr(args, "push_to_cloud", False): if not ocsf_output or not getattr(ocsf_output, "file_path", None): tmp_ocsf = tempfile.NamedTemporaryFile( suffix=json_ocsf_file_suffix, delete=False @@ -541,49 +561,50 @@ def prowler(): tmp_ocsf.close() ocsf_output.batch_write_data_to_file() print( - f"{Style.BRIGHT}\nExporting OCSF to Prowler Cloud, please wait...{Style.RESET_ALL}" + f"{Style.BRIGHT}\nPushing findings to Prowler Cloud, please wait...{Style.RESET_ALL}" ) try: response = send_ocsf_to_api(ocsf_output.file_path) except ValueError: print( - f"{Style.BRIGHT}{Fore.YELLOW}\nOCSF export skipped: no API key configured. " - "Set the PROWLER_API_KEY environment variable to enable it. " + f"{Style.BRIGHT}{Fore.YELLOW}\nPush to Prowler Cloud skipped: no API key configured. " + "Set the PROWLER_CLOUD_API_KEY environment variable to enable it. " f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" ) except requests.ConnectionError: print( - f"{Style.BRIGHT}{Fore.RED}\nOCSF export failed: could not reach the Prowler Cloud API at " + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: could not reach the Prowler Cloud API at " f"{cloud_api_base_url}. Check the URL and your network connection. " f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" ) except requests.HTTPError as http_err: if http_err.response.status_code == 402: print( - f"{Style.BRIGHT}{Fore.RED}\nOCSF export failed: " + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: " "this feature is only available with a Prowler Cloud subscription. " f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" ) else: print( - f"{Style.BRIGHT}{Fore.RED}\nOCSF export failed: the API returned HTTP {http_err.response.status_code}. " + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed: the API returned HTTP {http_err.response.status_code}. " "Verify your API key is valid and has the right permissions. " f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" ) except Exception as error: print( - f"{Style.BRIGHT}{Fore.RED}\nOCSF export failed unexpectedly: {error}. " + f"{Style.BRIGHT}{Fore.RED}\nPush to Prowler Cloud failed unexpectedly: {error}. " f"Scan results were saved to {ocsf_output.file_path}{Style.RESET_ALL}" ) else: job_id = response.get("data", {}).get("id") if response else None if job_id: print( - f"{Style.BRIGHT}{Fore.GREEN}\nOCSF export accepted. Ingestion job: {job_id}{Style.RESET_ALL}" + f"{Style.BRIGHT}{Fore.GREEN}\nFindings successfully pushed to Prowler Cloud. Ingestion job: {job_id}" + f"\nSee more details here: https://cloud.prowler.com/scans{Style.RESET_ALL}" ) else: logger.warning( - "OCSF export: unexpected API response (missing ingestion job ID). " + "Push to Prowler Cloud: unexpected API response (missing ingestion job ID). " f"Scan results were saved to {ocsf_output.file_path}" ) @@ -1128,6 +1149,48 @@ def prowler(): generated_outputs["compliance"].append(generic_compliance) generic_compliance.batch_write_data_to_file() + elif provider == "googleworkspace": + for compliance_name in input_compliance_frameworks: + if compliance_name.startswith("cis_"): + # Generate CIS Finding Object + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + cis = GoogleWorkspaceCIS( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(cis) + cis.batch_write_data_to_file() + elif compliance_name.startswith("cisa_scuba_"): + # Generate CISA SCuBA Finding Object + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + cisa_scuba = GoogleWorkspaceCISASCuBA( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(cisa_scuba) + cisa_scuba.batch_write_data_to_file() + else: + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + generic_compliance = GenericCompliance( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + create_file_descriptor=True, + file_path=filename, + ) + generated_outputs["compliance"].append(generic_compliance) + generic_compliance.batch_write_data_to_file() + elif provider == "oraclecloud": for compliance_name in input_compliance_frameworks: if compliance_name.startswith("cis_"): diff --git a/prowler/compliance/alibabacloud/secnumcloud_3.2_alibabacloud.json b/prowler/compliance/alibabacloud/secnumcloud_3.2_alibabacloud.json new file mode 100644 index 0000000000..e71dc8b869 --- /dev/null +++ b/prowler/compliance/alibabacloud/secnumcloud_3.2_alibabacloud.json @@ -0,0 +1,1432 @@ +{ + "Framework": "SecNumCloud", + "Name": "SecNumCloud Referentiel d'Exigences v3.2", + "Version": "3.2", + "Provider": "AlibabaCloud", + "Description": "The SecNumCloud framework is published by ANSSI (Agence Nationale de la Securite des Systemes d'Information) to qualify cloud service providers operating in France. Version 3.2, dated March 8, 2022, covers IaaS, CaaS, PaaS, and SaaS services with requirements spanning information security policies, access control, cryptography, physical security, operational security, communications security, and data sovereignty protections against extra-European law.", + "Requirements": [ + { + "Id": "5.1", + "Description": "Le prestataire doit definir et appliquer des principes de securite de l'information adaptes a ses activites de fourniture de services cloud.", + "Name": "Principes", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit operer la prestation a l'etat de l'art pour le type d'activite retenu : utiliser des logiciels stables beneficiant d'un suivi des correctifs de securite et parametres de facon a obtenir un niveau de securite optimal. b) Le prestataire doit appliquer le guide d'hygiene informatique de l'ANSSI [HYGIENE], niveau renforce, au systeme d'information du service." + } + ], + "Checks": [] + }, + { + "Id": "5.2", + "Description": "Le prestataire doit definir, faire approuver par la direction, publier et communiquer aux salaries et aux tiers concernes un ensemble de politiques de securite de l'information.", + "Name": "Politique de securite de l'information", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de securite de l'information relative au service. b) La politique de securite de l'information doit identifier les engagements du prestataire quant au respect de la legislation et reglementation nationale en vigueur selon la nature des informations qui pourraient etre confiees par le commanditaire au prestataire ; il revient en revanche in fine au commanditaire de s'assurer du respect des contraintes legales et reglementaires applicables aux donnees qu'il confie effectivement au prestataire. c) La politique de securite de l'information doit notamment couvrir les themes abordes aux chapitres 6 a 19 du present referentiel. d) La direction du prestataire doit approuver formellement la politique de securite de l'information. e) Le prestataire doit reviser annuellement la politique de securite de l'information et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "5.3", + "Description": "Le prestataire doit definir et appliquer un processus d'appreciation des risques de securite de l'information.", + "Name": "Appreciation des risques", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter une appreciation des risques couvrant l'ensemble du perimetre du service. b) Le prestataire doit realiser son appreciation de risques en utilisant une methode documentee garantissant la reproductibilite et comparabilite de la demarche. c) Le prestataire doit prendre en compte dans l'appreciation des risques : la gestion d'informations du commanditaire ayant des besoins de securite differents ; les risques ayant des impacts sur les droits et libertes des personnes concernees en cas d'acces non autorise, de modification non desiree et de disparition de donnees a caractere personnel ; les risques de defaillance des mecanismes de cloisonnement des ressources de l'infrastructure technique (memoire, calcul, stockage, reseau) partagees entre les commanditaires ; les risques lies a l'effacement incomplet ou non securise des donnees stockees sur les espaces de memoire ou de stockage partages entre commanditaires, en particulier lors des reallocations des espaces de memoire et de stockage ; les risques lies a l'exposition des interfaces d'administration sur un reseau public ; les risques d'atteinte a la confidentialite des donnees des commanditaires par des tiers impliques dans la fourniture du service (fournisseurs, sous-traitants, etc.) ; les risques lies aux evenements naturels et sinistres physiques ; les risques lies a la separation des taches (voir 6.2.a) ; les risques lies aux environnements de developpement (voir 14.4.b). d) Le prestataire doit lister, dans un document specifique, les risques residuels lies a l'existence de lois extra-europeennes ayant pour objectif la collecte de donnees ou metadonnees des commanditaires sans leur consentement prealable. e) Le prestataire doit mettre a la disposition du commanditaire, sur demande de celui-ci, les elements d'appreciation des risques lies a la soumission des donnees du commanditaire au droit d'un etat non-membre de l'Union Europeenne. f) Lorsqu'il existe des exigences legales, reglementaires ou sectorielles specifiques liees aux types d'informations confiees par le commanditaire au prestataire, ce dernier doit les prendre en compte dans son appreciation des risques en s'assurant de respecter l'ensemble des exigences du present referentiel d'une part et de ne pas abaisser le niveau de securite etabli par le respect des exigences du present referentiel d'autre part. g) La direction du prestataire doit accepter formellement les risques residuels identifies dans l'appreciation des risques. h) Le prestataire doit reviser annuellement l'appreciation des risques et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "6.1", + "Description": "Le prestataire doit definir et attribuer toutes les responsabilites en matiere de securite de l'information.", + "Name": "Fonctions et responsabilites liees a la securite de l'information", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une organisation interne de la securite pour assurer la definition, la mise en place et le suivi du fonctionnement operationnel de la securite de l'information au sein de son organisation. b) Le prestataire doit designer un responsable de la securite des systemes d'information et un responsable de la securite physique. c) Le prestataire doit definir et attribuer les responsabilites en matiere de securite de l'information pour le personnel implique dans la fourniture du service. d) Le prestataire doit s'assurer apres tout changement majeur pouvant avoir un impact sur le service que l'attribution des responsabilites en matiere de securite de l'information est toujours pertinente. e) Le prestataire doit definir et attribuer les responsabilites en matiere de protection de donnees a caractere personnel, en coherence avec son role dans les traitements de donnees a caractere personnel (responsable de traitement, sous-traitant ou co-responsable). f) Le prestataire doit, lorsqu'il traite un grand nombre de donnees parmi lesquelles figurent des categories particulieres de donnees a caractere personnel telles que definies dans [RGPD], designer un delegue a la protection des donnees. g) Il est recommande que le prestataire, quel que soit le volume de donnees a caractere personnel qu'il traite, designe un delegue a la protection des donnees. h) Le prestataire doit realiser ou contribuer a la realisation d'une analyse d'impact relative a la protection des donnees a caractere personnel lorsque le traitement est susceptible d'engendrer un risque eleve pour les droits et libertes des personnes concernees (traitement de categories particulieres de donnees a caractere personnel telles que definies dans [RGPD], traitement de donnees a grande echelle, etc.). Cette analyse doit comporter une evaluation juridique du respect des principes et droits fondamentaux, ainsi qu'une etude plus technique des mesures techniques mises en oeuvre pour proteger les personnes des risques pour leur vie privee." + } + ], + "Checks": [] + }, + { + "Id": "6.2", + "Description": "Le prestataire doit separer les taches et les domaines de responsabilite incompatibles afin de reduire les possibilites de modification non autorisee ou de mauvais usage des actifs.", + "Name": "Separation des taches", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les risques associes a des cumuls de responsabilites ou de taches, les prendre en compte dans l'appreciation des risques et mettre en oeuvre des mesures de reduction de ces risques." + } + ], + "Checks": [] + }, + { + "Id": "6.3", + "Description": "Le prestataire doit etablir et maintenir des relations appropriees avec les autorites competentes.", + "Name": "Relations avec les autorites", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire mette en place des relations appropriees avec les autorites competentes en matiere de securite de l'information et de donnees a caractere personnel et, le cas echeant, avec les autorites sectorielles selon la nature des informations confiees par le commanditaire au prestataire." + } + ], + "Checks": [] + }, + { + "Id": "6.4", + "Description": "Le prestataire doit etablir et maintenir des relations appropriees avec des groupes de travail specialises, des associations professionnelles ou des forums traitant de la securite.", + "Name": "Relations avec les groupes de travail specialises", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire entretienne des contacts appropries avec des groupes de specialistes ou des sources reconnues, notamment pour prendre en compte de nouvelles menaces et les mesures de securite appropriees pour les contrer." + } + ], + "Checks": [] + }, + { + "Id": "6.5", + "Description": "Le prestataire doit integrer la securite de l'information dans la gestion de projet, quel que soit le type de projet.", + "Name": "La securite de l'information dans la gestion de projet", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter une estimation des risques prealablement a tout projet pouvant avoir un impact sur le service, et ce quelle que soit la nature du projet. b) Dans la mesure ou un projet affecte ou est susceptible d'affecter le niveau de securite du service, le prestataire doit avertir le commanditaire et l'informer par ecrit des impacts potentiels, des mesures mises en place pour reduire ces impacts ainsi que des risques residuels le concernant." + } + ], + "Checks": [] + }, + { + "Id": "7.1", + "Description": "Le prestataire doit s'assurer que les candidats a l'embauche font l'objet de verifications proportionnees aux exigences metier, a la classification des informations accessibles et aux risques identifies.", + "Name": "Selection des candidats", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de verification des informations concernant son personnel conforme aux lois et reglements en vigueur. Ces verifications s'appliquent a toute personne impliquee dans la fourniture du service et doivent etre proportionnelles a la sensibilite ou a la specificite des informations du commanditaire confiees au prestataire ainsi qu'aux risques identifies. b) Pour les personnels disposant de privileges d'administration eleves sur les composants logiciels et materiels de l'infrastructure, le prestataire doit renforcer les verifications destinees a verifier que les antecedents de ceux-ci ne sont pas incompatibles avec l'exercice de leurs fonctions. Il est entendu par des privileges d'administration eleves, des actions permettant l'elevation de privileges ou la possibilite de realiser des actions sans traces techniques ou de desactiver, alterer les traces techniques." + } + ], + "Checks": [] + }, + { + "Id": "7.2", + "Description": "Les accords contractuels avec les salaries et les sous-traitants doivent preciser leurs responsabilites et celles du prestataire en matiere de securite de l'information.", + "Name": "Conditions d'embauche", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit disposer d'une charte d'ethique integree au reglement interieur, prevoyant notamment que : les prestations sont realisees avec loyaute, discretion et impartialite et dans des conditions de confidentialite des informations traitees ; les personnels ne recourent qu'aux methodes, outils et techniques valides par le prestataire ; les personnels s'engagent a ne pas divulguer d'informations a un tiers, meme anonymisees et decontextualisees, obtenues ou generees dans le cadre de la prestation sauf autorisation formelle et ecrite du commanditaire ; les personnels s'engagent a signaler au prestataire tout contenu manifestement illicite decouvert pendant la prestation ; les personnels s'engagent a respecter la legislation et la reglementation nationale en vigueur et les bonnes pratiques liees a leurs activites. b) Le prestataire doit faire signer la charte d'ethique a l'ensemble des personnes impliquees dans la fourniture du service. c) Le prestataire doit introduire, dans le contrat de travail des personnels disposant de privileges d'administration eleves sur les composants et materiels de l'infrastructure du service, un engagement de responsabilite avec un renvoi aux clauses du code du travail sur la protection du secret des affaires et de la propriete intellectuelle. Il est entendu par des privileges d'administration eleves, des actions permettant l'elevation de privileges ou la possibilite de realiser des actions sans traces techniques ou de desactiver, alterer les traces techniques. d) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible le reglement interieur et la charte d'ethique." + } + ], + "Checks": [] + }, + { + "Id": "7.3", + "Description": "Les salaries du prestataire et, le cas echeant, les sous-traitants doivent suivre un programme de sensibilisation et de formation adapte et regulier concernant la securite de l'information.", + "Name": "Sensibilisation, apprentissage et formations a la securite de l'information", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit sensibiliser a la securite de l'information et aux risques lies a la protection des donnees l'ensemble des personnes impliquees dans la fourniture du service. Il doit leur communiquer les mises a jour des politiques et procedures pertinentes dans le cadre de leurs missions. b) Le prestataire doit documenter et mettre en oeuvre un plan de formation concernant la securite de l'information adapte au service et aux missions des personnels. c) Le responsable de la securite des systemes d'information du prestataire doit valider formellement le plan de formation concernant la securite de l'information." + } + ], + "Checks": [] + }, + { + "Id": "7.4", + "Description": "Le prestataire doit mettre en place un processus disciplinaire formel et communique pour prendre des mesures a l'encontre des salaries ayant enfreint les regles de securite de l'information.", + "Name": "Processus disciplinaire", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus disciplinaire applicable a l'ensemble des personnes impliquees dans la fourniture du service ayant enfreint la politique de securite. b) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible les sanctions encourues en cas d'infraction a la politique de securite." + } + ], + "Checks": [] + }, + { + "Id": "7.5", + "Description": "Les responsabilites et les obligations en matiere de securite de l'information qui restent valables apres un changement ou une rupture du contrat de travail doivent etre definies, communiquees au salarie ou au sous-traitant et appliquees.", + "Name": "Rupture, terme ou modification du contrat de travail", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit definir et attribuer les roles et les responsabilites relatives a la rupture, au terme ou a la modification de tout contrat avec une personne impliquee dans la fourniture du service." + } + ], + "Checks": [] + }, + { + "Id": "8.1", + "Description": "Le prestataire doit identifier les actifs associes a l'information et aux moyens de traitement de l'information et doit etablir et tenir a jour un inventaire de ces actifs.", + "Name": "Inventaire et propriete des actifs", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "securitycenter", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit tenir a jour l'inventaire de l'ensemble des equipements mettant en oeuvre le service. Cet inventaire doit preciser pour chaque equipement : les informations d'identification de l'equipement (noms, adresses IP, adresses MAC, etc.) ; la fonction de l'equipement ; le modele de l'equipement ; la localisation de l'equipement ; le proprietaire de l'equipement ; le besoin de securite des informations (au sens du chapitre 8.3). b) Le prestataire doit tenir a jour l'inventaire de l'ensemble des logiciels mettant en oeuvre le service. Cet inventaire doit identifier pour chaque logiciel, sa version et les equipements sur lesquels le logiciel est installe. c) Le prestataire doit s'assurer de la validite des licences des logiciels tout au long de la prestation." + } + ], + "Checks": [ + "securitycenter_all_assets_agent_installed" + ] + }, + { + "Id": "8.2", + "Description": "Les salaries et les utilisateurs de tiers doivent restituer tous les actifs du prestataire en leur possession au terme de la periode d'emploi, du contrat ou de l'accord.", + "Name": "Restitution des actifs", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de restitution des actifs permettant de s'assurer que chaque personne impliquee dans la fourniture du service restitue l'ensemble des actifs en sa possession a la fin de sa periode d'emploi ou de son contrat." + } + ], + "Checks": [] + }, + { + "Id": "8.3", + "Description": "Les besoins de protection de la confidentialite, de l'integrite et de la disponibilite de l'information doivent etre identifies.", + "Name": "Identification des besoins de securite de l'information", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les differents besoins de securite des informations relatives au service. b) Lorsque le commanditaire confie au prestataire des donnees soumises a des contraintes legales, reglementaires ou sectorielles specifiques, le prestataire doit identifier les besoins de securite specifiques associes a ces contraintes." + } + ], + "Checks": [] + }, + { + "Id": "8.4", + "Description": "Un ensemble de procedures appropriees pour le marquage et la manipulation de l'information doit etre elabore et mis en oeuvre conformement au plan de classification adopte par le prestataire.", + "Name": "Marquage et manipulation de l'information", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire documente et mette en oeuvre une procedure pour le marquage et la manipulation de toutes les informations participant a la delivrance du service, conformement a son besoin de securite defini au chapitre 8.3." + } + ], + "Checks": [] + }, + { + "Id": "8.5", + "Description": "Des procedures de gestion des supports amovibles doivent etre mises en oeuvre conformement au plan de classification adopte par le prestataire.", + "Name": "Gestion des supports amovibles", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure pour la gestion des supports amovibles, conformement au besoin de securite defini au chapitre 8.3. Lorsque des supports amovibles sont utilises sur l'infrastructure technique ou pour des taches d'administration, ces supports doivent etre dedies a un usage." + } + ], + "Checks": [] + }, + { + "Id": "9.1", + "Description": "Une politique de controle d'acces doit etre etablie, documentee et revue en se basant sur les exigences metier et les exigences de securite de l'information. Les regles de controle d'acces et les droits pour chaque utilisateur ou groupe d'utilisateurs doivent etre clairement definis.", + "Name": "Politiques et controle d'acces", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "ram", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de controle d'acces sur la base du resultat de son appreciation des risques et du partage des responsabilites. b) Le prestataire doit reviser annuellement la politique de controle d'acces et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [ + "ram_policy_no_administrative_privileges", + "ram_policy_attached_only_to_group_or_roles" + ] + }, + { + "Id": "9.2", + "Description": "Un processus formel d'enregistrement et de desinscription des utilisateurs doit etre mis en oeuvre pour permettre l'attribution des droits d'acces.", + "Name": "Enregistrement et desinscription des utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "ram", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure d'enregistrement et de desinscription des utilisateurs s'appuyant sur une interface de gestion des comptes et des droits d'acces. Cette procedure doit indiquer quelles donnees doivent etre supprimees au depart d'un utilisateur. b) Le prestataire doit attribuer des comptes nominatifs lors de l'enregistrement des utilisateurs places sous sa responsabilite. c) Le prestataire doit mettre en oeuvre des moyens permettant de s'assurer que la desinscription d'un utilisateur entraine la suppression de tous ses acces aux ressources du systeme d'information du service, ainsi que la suppression de ses donnees conformement a la procedure d'enregistrement et de desinscription (voir exigence 9.2 a))." + } + ], + "Checks": [ + "ram_user_console_access_unused" + ] + }, + { + "Id": "9.3", + "Description": "Un processus formel de gestion des droits d'acces doit etre mis en oeuvre pour controler l'attribution des droits d'acces a tous les types d'utilisateurs et a tous les systemes et services.", + "Name": "Gestion des droits d'acces", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "ram", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'assurer l'attribution, la modification et le retrait de droits d'acces aux ressources du systeme d'information du service. b) Le prestataire doit mettre a la disposition de ses commanditaires les outils et les moyens qui permettent une differenciation des roles des utilisateurs du service, par exemple suivant leur role fonctionnel. c) Le prestataire doit tenir a jour l'inventaire des utilisateurs sous sa responsabilite disposant de droits d'administration sur les ressources du systeme d'information du service. d) Le prestataire doit etre en mesure de fournir, pour une ressource donnee mettant en oeuvre le service, la liste de tous les utilisateurs y ayant acces, qu'ils soient sous la responsabilite du prestataire ou du commanditaire ainsi que les droits d'acces qui leurs ont ete attribues. e) Le prestataire doit etre en mesure de fournir, pour un utilisateur donne, qu'ils soient sous la responsabilite du prestataire ou du commanditaire, la liste de tous ses droits d'acces sur les differents elements du systeme d'information du service. f) Le prestataire doit definir une liste de droits d'acces incompatibles entre eux. Il doit s'assurer, lors de l'attribution de droits d'acces a un utilisateur qu'il ne possede pas de droits d'acces incompatibles entre eux au titre de la liste precedemment etablie. g) Le prestataire doit inclure dans la procedure de gestion des droits d'acces les actions de revocation ou de suspension des droits de tout utilisateur." + } + ], + "Checks": [ + "ram_policy_no_administrative_privileges", + "ram_policy_attached_only_to_group_or_roles", + "ram_no_root_access_key" + ] + }, + { + "Id": "9.4", + "Description": "Les proprietaires d'actifs doivent verifier les droits d'acces des utilisateurs a intervalles reguliers.", + "Name": "Revue des droits d'acces utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "ram", + "Type": "Automated", + "Comment": "a) Le prestataire doit reviser annuellement les droits d'acces des utilisateurs sur son perimetre de responsabilite. b) Le prestataire doit mettre a disposition du commanditaire un outil facilitant la revue des droits d'acces des utilisateurs places sous la responsabilite de ce dernier. c) Le prestataire doit reviser trimestriellement la liste des utilisateurs sur son perimetre de responsabilite pouvant utiliser les comptes techniques mentionnes dans l'exigence 9.2 b)." + } + ], + "Checks": [ + "ram_rotate_access_key_90_days", + "ram_user_console_access_unused" + ] + }, + { + "Id": "9.5", + "Description": "L'attribution et l'utilisation des informations secretes d'authentification doivent etre gerees dans le cadre d'un processus de gestion formel incluant une politique de mot de passe robuste et l'utilisation de l'authentification multi-facteur.", + "Name": "Gestion des authentifications des utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "ram", + "Type": "Automated", + "Comment": "a) Le prestataire doit formaliser et mettre en oeuvre des procedures de gestion de l'authentification des utilisateurs. En accord avec les exigences du chapitre 10, celles-ci doivent notamment porter sur : la gestion des moyens d'authentification (emission et reinitialisation de mot de passe, mise a jour des CRL et import des certificats racines en cas d'utilisation de certificats, etc.) ; la mise en place des moyens permettant une authentification a multiples facteurs afin de repondre aux differents cas d'usage du referentiel ; les systemes qui generent des mots de passe ou verifient leur robustesse, lorsqu'une authentification par mot de passe est utilisee. Ils doivent suivre les recommandations de [G_AUTH]. b) Tout mecanisme d'authentification doit prevoir le blocage d'un compte apres un nombre limite de tentatives infructueuses. c) Dans le cadre d'un service SaaS, le prestataire doit proposer a ses commanditaires des moyens d'authentification a multiples facteurs pour l'acces des utilisateurs finaux. d) Lorsque des comptes techniques, non nominatifs, sont necessaires, le prestataire doit mettre en place des mesures obligeant les utilisateurs a s'authentifier avec leur compte nominatif avant de pouvoir acceder a ces comptes techniques." + } + ], + "Checks": [ + "ram_password_policy_minimum_length", + "ram_password_policy_lowercase", + "ram_password_policy_uppercase", + "ram_password_policy_number", + "ram_password_policy_symbol", + "ram_password_policy_max_password_age", + "ram_password_policy_password_reuse_prevention", + "ram_password_policy_max_login_attempts", + "ram_user_mfa_enabled_console_access" + ] + }, + { + "Id": "9.6", + "Description": "L'acces aux interfaces d'administration du service cloud doit etre restreint et protege par des mecanismes d'authentification forte, incluant l'utilisation de dispositifs MFA materiels pour les comptes a privileges.", + "Name": "Acces aux interfaces d'administration", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "ram", + "Type": "Automated", + "Comment": "a) Les comptes d'administration sous la responsabilite du prestataire doivent etre geres a l'aide d'outils et d'annuaires distincts de ceux utilises pour la gestion des comptes utilisateurs places sous la responsabilite du commanditaire. b) Les interfaces d'administration mises a disposition des commanditaires doivent etre distinctes des interfaces d'administration utilisees par le prestataire. c) Les interfaces d'administration mises a disposition des commanditaires ne doivent permettre aucune connexion avec des comptes d'administrateurs sous la responsabilite du prestataire. d) Les interfaces d'administration utilisees par le prestataire ne doivent pas etre accessibles a partir d'un reseau public et ainsi ne doivent permettre aucune connexion des utilisateurs sous la responsabilite du commanditaire. e) Si des interfaces d'administration sont mises a disposition des commanditaires avec un acces via un reseau public, les flux d'administration doivent etre authentifies et chiffres avec des moyens en accord avec les exigences du chapitre 10.2. f) Le prestataire doit mettre en place un systeme d'authentification multifacteur fort pour l'acces : aux interfaces d'administration utilisees par le prestataire ; aux interfaces d'administration dediees aux commanditaires. g) Dans le cadre d'un service SaaS, les interfaces d'administration mises a disposition des commanditaires doivent etre differenciees des interfaces permettant l'acces des utilisateurs finaux. h) Des lors qu'une interface d'administration est accessible depuis un reseau public, le processus d'authentification doit avoir lieu avant toute interaction entre l'utilisateur et l'interface en question. i) Lorsque le prestataire utilise un service de type IaaS comme socle d'un autre type de service (CaaS, PaaS ou SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service IaaS. j) Lorsque le prestataire utilise un service de type CaaS comme socle d'un autre type de service (PaaS ou SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service CaaS. k) Lorsque le prestataire utilise un service de type PaaS comme socle d'un autre type de service (typiquement SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service PaaS." + } + ], + "Checks": [ + "ram_no_root_access_key", + "ram_user_mfa_enabled_console_access", + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet" + ] + }, + { + "Id": "9.7", + "Description": "L'acces a l'information et aux fonctions d'application des systemes doit etre restreint conformement a la politique de controle d'acces. Les ressources doivent etre protegees contre tout acces public non autorise.", + "Name": "Restriction des acces a l'information", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "ecs", + "Type": "Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre des mesures de cloisonnement appropriees entre ses commanditaires. b) Le prestataire doit mettre en oeuvre des mesures de cloisonnement appropriees entre le systeme d'information du service et ses autres systemes d'information (bureautique, informatique de gestion, gestion technique du batiment, controle d'acces physique, etc.). c) Le prestataire doit concevoir, developper, configurer et deployer le systeme d'information du service en assurant au moins un cloisonnement entre d'une part l'infrastructure technique et d'autre part les equipements necessaires a l'administration des services et des ressources qu'elle heberge. d) Dans le cadre du support technique, si les actions necessaires au diagnostic et a la resolution d'un probleme rencontre par un commanditaire necessitent un acces aux donnees du commanditaire, alors le prestataire doit : n'autoriser l'acces aux donnees du commanditaire qu'apres consentement explicite du commanditaire ; verifier que la personne a qui l'acces doit etre autorise a satisfait aux verifications de l'exigence 7.1.b ; dans le cas d'une intervention realisee a distance par une personne localisee hors de l'Union Europeenne, mettre en oeuvre une passerelle securisee (poste de rebond) par laquelle la personne devra se connecter et permettant une supervision (autorisation ou interdiction des actions, demandes d'explications, etc.) en temps reel, par une personne ayant elle-meme satisfait aux verifications de l'exigence 7.1.b ; considerer les actions menees, une fois l'acces autorise, comme des actions d'administration et les journaliser comme telles ; supprimer l'autorisation d'acces aux donnees du commanditaire au terme de ces actions." + } + ], + "Checks": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet", + "oss_bucket_not_publicly_accessible", + "ecs_instance_no_legacy_network", + "rds_instance_no_public_access_whitelist" + ] + }, + { + "Id": "10.1", + "Description": "Les donnees stockees dans le cadre du service cloud doivent etre chiffrees au repos en utilisant des algorithmes et des longueurs de cle conformes a l'etat de l'art.", + "Name": "Chiffrement des donnees stockees", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "ecs", + "Type": "Automated", + "Comment": "a) Le prestataire doit definir et mettre en oeuvre un mecanisme de chiffrement empechant la recuperation des donnees des commanditaires en cas de reallocation d'une ressource ou de recuperation du support physique. Dans le cas d'un service IaaS ou CaaS, cet objectif pourra par exemple etre atteint par un chiffrement du disque ou du systeme de fichier, lorsque le protocole d'acces en mode fichiers garantit que seuls des blocs vides peuvent etre alloues, ou par un chiffrement par volume dans le cas d'un acces en mode bloc, avec au moins une cle par commanditaire. Dans le cas d'un service PaaS ou SaaS, cet objectif pourra etre atteint en utilisant un chiffrement applicatif dans le perimetre du prestataire, avec au moins une cle par commanditaire. b) Le prestataire doit utiliser une methode de chiffrement des donnees respectant les regles de [CRYPTO_B1]. c) Il est recommande d'utiliser une methode de chiffrement des donnees respectant les recommandations de [CRYPTO_B1]. d) Le prestataire doit mettre en place un chiffrement des donnees sur les supports amovibles et les supports de sauvegarde amenes a quitter le perimetre de securite physique du systeme d'information du service (au sens du chapitre 10), en fonction du besoin de securite des donnees (voir chapitre 8.3)." + } + ], + "Checks": [ + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom" + ] + }, + { + "Id": "10.2", + "Description": "Les flux de donnees entre les composants du service cloud et entre le service et les commanditaires doivent etre chiffres en transit en utilisant des protocoles et des algorithmes conformes a l'etat de l'art.", + "Name": "Chiffrement des flux", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "oss", + "Type": "Automated", + "Comment": "a) Lorsque le prestataire met en oeuvre un mecanisme de chiffrement des flux reseau, celui-ci doit respecter les regles de [CRYPTO_B1]. b) Lorsque le prestataire met en oeuvre un mecanisme de chiffrement des flux reseau, il est recommande que celui-ci respecte les recommandations de [CRYPTO_B1]. c) Si le protocole TLS est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_TLS]. d) Si le protocole IPsec est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_IPSEC]. e) Si le protocole SSH est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_SSH]." + } + ], + "Checks": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled" + ] + }, + { + "Id": "10.3", + "Description": "Les mots de passe doivent etre stockes sous forme hachee en utilisant des algorithmes robustes conformes a l'etat de l'art et les politiques de mot de passe doivent imposer des exigences de complexite adequates.", + "Name": "Hachage des mots de passe", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "ram", + "Type": "Partially Automated", + "Comment": "a) Le prestataire ne doit stocker que l'empreinte des mots de passe des utilisateurs et des comptes techniques. b) Le prestataire doit mettre en oeuvre une fonction de hachage respectant les regles de [CRYPTO_B1]. c) Il est recommande que le prestataire mette en oeuvre une fonction de hachage respectant les recommandations de [CRYPTO_B1]. d) Le prestataire doit generer les empreintes des mots de passe avec une fonction de hachage associee a l'utilisation d'un sel cryptographique respectant les regles de [CRYPTO_B1]." + } + ], + "Checks": [ + "ram_password_policy_minimum_length", + "ram_password_policy_symbol", + "ram_password_policy_number" + ] + }, + { + "Id": "10.4", + "Description": "Des mecanismes de non-repudiation doivent etre mis en oeuvre pour assurer la tracabilite des actions effectuees sur le service cloud, incluant la validation de l'integrite des journaux.", + "Name": "Non repudiation", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "actiontrail", + "Type": "Partially Automated", + "Comment": "a) Lorsque le prestataire met en oeuvre un mecanisme de signature electronique, celui-ci doit respecter les regles de [CRYPTO_B1]. b) Lorsque le prestataire met en oeuvre un mecanisme de signature electronique, il est recommande que celui-ci respecte les recommandations de [CRYPTO_B1]." + } + ], + "Checks": [ + "actiontrail_oss_bucket_not_publicly_accessible" + ] + }, + { + "Id": "10.5", + "Description": "Les secrets cryptographiques (cles, certificats, mots de passe) doivent etre geres de maniere securisee tout au long de leur cycle de vie, incluant la generation, le stockage, la distribution, la rotation et la destruction.", + "Name": "Gestion des secrets", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "ram", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre des cles cryptographiques respectant les regles de [CRYPTO_B2]. b) Il est recommande que le prestataire mette en oeuvre des cles cryptographiques respectant les recommandations de [CRYPTO_B2]. c) Le prestataire doit proteger l'acces aux cles cryptographiques et autres secrets utilises pour le chiffrement des donnees par un moyen adapte : conteneur de securite (logiciel ou materiel) ou support disjoint. d) Le prestataire doit proteger l'acces aux cles cryptographiques et autres secrets utilises pour les taches d'administration par un conteneur de securite adapte, logiciel ou materiel." + } + ], + "Checks": [ + "sls_customer_created_cmk_changes_alert_enabled", + "ram_rotate_access_key_90_days" + ] + }, + { + "Id": "10.6", + "Description": "Les racines de confiance (certificats racine, autorites de certification) utilisees dans le cadre du service cloud doivent etre gerees de maniere securisee. Les certificats doivent etre valides et utiliser des algorithmes de cle robustes.", + "Name": "Racines de confiance", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "general", + "Type": "Manual", + "Comment": "a) Sur l'infrastructure technique, le prestataire doit utiliser exclusivement des certificats de cle publique issus d'une autorite de certification d'un Etat membre de l'Union Europeenne (les ceremonies de generation des cles maitresses doivent avoir lieu dans un pays membre de l'Union Europeenne et en presence du prestataire)." + } + ], + "Checks": [] + }, + { + "Id": "11.1", + "Description": "Des perimetres de securite doivent etre definis et utilises pour proteger les zones contenant des informations sensibles ou critiques et les moyens de traitement de l'information.", + "Name": "Perimetres de securite physique", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des perimetres de securite, incluant le marquage des zones et les differents moyens de limitation et de controle des acces. b) Le prestataire doit distinguer des zones publiques, des zones privees et des zones sensibles. 11.1.1. Zones publiques : a) Les zones publiques sont accessibles a tous dans les limites de la propriete du prestataire. Le prestataire ne doit heberger aucune ressource devolue au service ou permettant d'acceder a des composantes de celui-ci dans les zones publiques. 11.1.2. Zones privees : a) Les zones privees peuvent heberger : les plateformes et moyens de developpement du service ; les postes d'administration, d'exploitation et de supervision ; les locaux a partir desquels le prestataire opere. 11.1.3. Zones sensibles : a) Les zones sensibles sont reservees a l'hebergement du systeme d'information de production du service hors postes d'administration, d'exploitation et de supervision." + } + ], + "Checks": [] + }, + { + "Id": "11.2", + "Description": "Les zones securisees doivent etre protegees par des controles d'acces physiques adequats pour s'assurer que seul le personnel autorise est admis.", + "Name": "Controle d'acces physique", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "11.2.1. Zones privees : a) Le prestataire doit proteger les zones privees contre les acces non autorises. Pour ce faire, il doit mettre en oeuvre un controle d'acces physique reposant au moins sur un facteur personnel : la connaissance d'un secret, la detention d'un objet ou la biometrie. b) Il est recommande que le prestataire respecte les recommandations de [G_CVAP] pour mettre en oeuvre du controle d'acces physique. c) Le prestataire doit definir et documenter des mesures d'acces physique derogatoires en cas d'urgence. d) Le prestataire doit afficher a l'entree des zones privees un avertissement relatif aux limites et conditions d'acces a ces zones. e) Le prestataire doit definir et documenter les plages horaires et conditions d'acces aux zones privees en fonction des profils des intervenants. f) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de s'assurer que les visiteurs sont systematiquement accompagnes par le prestataire lors de leurs acces et sejours en zone privee. Le prestataire doit conserver une trace de l'identite des visiteurs conformement a la legislation et reglementation en vigueur. g) En cas d'intervention (actions de diagnostic, de maintenance, ou d'administration) en zone privee par un tiers visiteur, le prestataire doit faire superviser (suivre, autoriser, interdire, questionner) les actions par un personnel ayant satisfait aux verifications de l'exigence 7.1.b. h) Le prestataire doit documenter et mettre en oeuvre des mecanismes de surveillance et de detection des acces non autorises aux zones privees. 11.2.2. Zones sensibles : a) Le prestataire doit proteger les zones sensibles contre les acces non autorises. Pour ce faire, il doit mettre en oeuvre un controle d'acces physique reposant au moins sur deux facteurs personnels : la connaissance d'un secret, la detention d'un objet ou la biometrie. b) Il est recommande que le prestataire respecte les recommandations de [G_CVAP] pour la mise en oeuvre du controle d'acces physique. c) Le prestataire doit definir et documenter des mesures d'acces physique derogatoires en cas d'urgence. d) Le prestataire doit afficher a l'entree des zones sensibles un avertissement relatif aux limites et conditions d'acces a ces zones. e) Le prestataire doit definir et documenter les plages horaires et conditions d'acces aux zones sensibles en fonction des profils des intervenants. f) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de s'assurer que les visiteurs sont systematiquement accompagnes par le prestataire lors de leurs acces et sejours en zone sensible. Le prestataire doit conserver une trace de l'identite des visiteurs conformement a la legislation et reglementation en vigueur. g) En cas d'intervention (actions de diagnostic, de maintenance, ou d'administration) en zone sensible par un tiers visiteur, le prestataire doit faire superviser (suivre, autoriser, interdire, questionner) les actions par un personnel ayant satisfait aux verifications de l'exigence 7.1.b. h) Le prestataire doit documenter et mettre en oeuvre des mecanismes de surveillance et de detection des acces non autorises aux zones sensibles. i) Le prestataire doit mettre en place une journalisation des acces physiques aux zones sensibles. Il doit effectuer une revue de ces journaux au moins mensuellement. j) Le prestataire doit mettre en oeuvre les moyens garantissant qu'aucun acces direct n'existe entre une zone publique et une zone sensible." + } + ], + "Checks": [] + }, + { + "Id": "11.3", + "Description": "Des mesures de protection contre les menaces exterieures et environnementales, telles que les catastrophes naturelles, les attaques malveillantes ou les accidents, doivent etre concues et appliquees.", + "Name": "Protection contre les menaces exterieures et environnementales", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de minimiser les risques inherents aux sinistres physiques (incendie, degat des eaux, etc.) et naturels (risques climatiques, inondations, seismes, etc.). b) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de limiter les risques de depart et de propagation de feu ainsi que les risques de degat des eaux. c) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de prevenir et limiter les consequences d'une coupure d'alimentation electrique et permettre une reprise du service conformement aux exigences de disponibilite du service definies dans la convention de service. d) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de maintenir des conditions de temperature et d'humidite adaptees aux equipements. De plus, il doit mettre en oeuvre des mesures permettant de prevenir les pannes de climatisation et d'en limiter les consequences. e) Le prestataire doit documenter et mettre en oeuvre des controles et tests reguliers des equipements de detection et de protection physique." + } + ], + "Checks": [] + }, + { + "Id": "11.4", + "Description": "Des mesures de securite physique pour le travail dans les zones privees et sensibles doivent etre concues et appliquees.", + "Name": "Travail dans les zones privees et sensibles", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit integrer les elements de securite physique dans la politique de securite et l'appreciation des risques conformement au niveau de securite requis par la categorie de la zone. b) Le prestataire doit documenter et mettre en oeuvre des procedures relatives au travail en zones privees et sensibles. Il doit communiquer ces procedures aux intervenants concernes." + } + ], + "Checks": [] + }, + { + "Id": "11.5", + "Description": "Les points d'acces tels que les zones de livraison et de chargement et les autres points par lesquels des personnes non autorisees peuvent penetrer dans les locaux doivent etre controles.", + "Name": "Zones de livraison et de chargement", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Les zones de livraison et de chargement et les autres points par lesquels des personnes non autorisees peuvent penetrer dans les locaux sans etre accompagnees sont considerees comme des zones publiques. b) Le prestataire doit isoler les points d'acces de ces zones vers les zones privees et sensibles, de facon a eviter les acces non autorises, ou a defaut, implementer des mesures compensatoires permettant d'assurer le meme niveau de securite." + } + ], + "Checks": [] + }, + { + "Id": "11.6", + "Description": "Le cablage electrique et de telecommunications transportant des donnees ou supportant des services d'information doit etre protege contre les interceptions, les interferences ou les dommages.", + "Name": "Securite du cablage", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de proteger le cablage electrique et de telecommunication des dommages physiques et des possibilites d'interception. b) Le prestataire doit etablir et tenir a jour un plan de cablage. c) Il est recommande que le prestataire mette en oeuvre des mesures permettant d'identifier les cables (par exemple code couleur, etiquette, etc.) afin d'en faciliter l'exploitation et limiter les erreurs de manipulation." + } + ], + "Checks": [] + }, + { + "Id": "11.7", + "Description": "Les materiels doivent etre entretenus correctement pour garantir leur disponibilite permanente et leur integrite.", + "Name": "Maintenance des materiels", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de s'assurer que les conditions d'installation, de maintenance et d'entretien des equipements du systeme d'information du service heberges en zones privees et sensibles sont compatibles avec les exigences de confidentialite et de disponibilite du service definies dans la convention de service. b) Le prestataire doit souscrire des contrats de maintenance permettant de disposer des mises a jour de securite des logiciels installes sur les equipements du systeme d'information du service. c) Le prestataire doit s'assurer que les supports ne peuvent etre retournes a un tiers que si les donnees du commanditaire y sont stockees chiffrees conformement au chapitre 10.1 ou ont prealablement ete detruites a l'aide d'un mecanisme d'effacement securise par reecriture de motifs aleatoires. d) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de s'assurer que les conditions d'installation, de maintenance et d'entretien des equipements techniques annexes (alimentation electrique, climatisation, incendie, etc.) sont compatibles avec les exigences de disponibilite du service definies dans la convention de service." + } + ], + "Checks": [] + }, + { + "Id": "11.8", + "Description": "Les materiels, les informations ou les logiciels ne doivent pas etre sortis des locaux du prestataire sans autorisation prealable.", + "Name": "Sortie des actifs", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de transfert hors site de donnees du commanditaire, equipements et logiciels. Cette procedure doit necessiter que la direction du prestataire donne son autorisation ecrite. Dans tous les cas, le prestataire doit mettre en oeuvre les moyens permettant de garantir que le niveau de protection en confidentialite et en integrite des actifs durant leur transport est equivalent a celui sur site." + } + ], + "Checks": [] + }, + { + "Id": "11.9", + "Description": "Tous les composants des equipements contenant des supports de stockage doivent etre verifies pour s'assurer que toute donnee sensible et tout logiciel sous licence ont ete supprimes ou ecrases de facon securisee avant leur mise au rebut ou leur reutilisation.", + "Name": "Recyclage securise du materiel", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des moyens permettant d'effacer de maniere securisee par reecriture de motifs aleatoires tout support de donnees mis a disposition d'un commanditaire. Si l'espace de stockage est chiffre dans le cadre de l'exigence 10.1.a), l'effacement peut etre realise par un effacement securise de la cle de chiffrement." + } + ], + "Checks": [] + }, + { + "Id": "11.10", + "Description": "Le materiel en attente d'utilisation doit etre protege de maniere adequate.", + "Name": "Materiel en attente d'utilisation", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de protection du materiel en attente d'utilisation." + } + ], + "Checks": [] + }, + { + "Id": "12.1", + "Description": "Les procedures d'exploitation doivent etre documentees et mises a disposition de tous les utilisateurs concernes.", + "Name": "Procedures d'exploitation documentees", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter les procedures d'exploitation, les tenir a jour et les rendre accessibles au personnel concerne." + } + ], + "Checks": [] + }, + { + "Id": "12.2", + "Description": "Les changements apportes au systeme d'information du prestataire, aux processus metier, aux moyens de traitement de l'information et aux systemes qui ont une incidence sur la securite de l'information doivent etre geres.", + "Name": "Gestion des changements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "actiontrail", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de gestion des changements apportes aux systemes et moyens de traitement de l'information. b) Le prestataire doit documenter et mettre en oeuvre une procedure permettant, en cas d'operations realisees par le prestataire et pouvant avoir un impact sur la securite ou la disponibilite du service, de communiquer au plus tot a l'ensemble de ses commanditaires les informations suivantes : la date et l'heure programmees du debut et de la fin des operations ; la nature des operations ; les impacts sur la securite ou la disponibilite du service ; le contact au sein du prestataire. c) Dans le cadre d'un service PaaS, le prestataire doit informer au plus tot le commanditaire de toute modification a venir sur des elements logiciels sous sa responsabilite des lors que la compatibilite complete ne peut etre assuree. d) Le prestataire doit informer au plus tot le commanditaire de toute modification a venir sur les elements du service des lors qu'elle est susceptible d'occasionner une perte de fonctionnalite pour le commanditaire." + } + ], + "Checks": [ + "actiontrail_multi_region_enabled", + "sls_logstore_retention_period" + ] + }, + { + "Id": "12.3", + "Description": "Les environnements de developpement, de test et d'exploitation doivent etre separes pour reduire les risques d'acces non autorise ou de changements non souhaites dans l'environnement d'exploitation.", + "Name": "Separation des environnements de developpement, de test et d'exploitation", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de separer physiquement les environnements lies a la production du service des autres environnements, dont les environnements de developpement." + } + ], + "Checks": [] + }, + { + "Id": "12.4", + "Description": "Des mesures de detection, de prevention et de recuperation conjuguees a une sensibilisation des utilisateurs doivent etre mises en oeuvre pour proteger le systeme d'information contre les codes malveillants.", + "Name": "Mesures contre les codes malveillants", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "securitycenter", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures de detection, de prevention et de restauration pour se proteger des codes malveillants. Le perimetre d'application de cette exigence sur le systeme d'information du service doit necessairement contenir les postes utilisateurs sous la responsabilite du prestataire et les flux entrants sur ce meme systeme d'information. b) Le prestataire doit documenter et mettre en oeuvre une sensibilisation de ses employes aux risques lies aux codes malveillants et aux bonnes pratiques pour reduire l'impact d'une infection." + } + ], + "Checks": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_all_assets_agent_installed", + "securitycenter_vulnerability_scan_enabled", + "securitycenter_notification_enabled_high_risk", + "ecs_instance_endpoint_protection_installed" + ] + }, + { + "Id": "12.5", + "Description": "Des copies de sauvegarde des informations, des logiciels et des images systeme doivent etre effectuees et testees regulierement conformement a une politique de sauvegarde convenue.", + "Name": "Sauvegarde des informations", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de sauvegarde et de restauration des donnees sous sa responsabilite dans le cadre du service. Cette politique doit prevoir une sauvegarde quotidienne de l'ensemble des donnees (informations, logiciels, configurations, etc.) sous la responsabilite du prestataire dans le cadre du service. b) Le prestataire doit documenter et mettre en oeuvre des mesures de protection des sauvegardes conformement a la politique de controle d'acces (voir chapitre 9). Cette politique doit prevoir une revue mensuelle des traces d'acces aux sauvegardes. c) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester regulierement la restauration des sauvegardes. d) Le prestataire doit localiser les sauvegardes a une distance suffisante des equipements principaux en coherence avec les resultats de l'appreciation de risques et permettant de faire face a des sinistres majeurs. Les sauvegardes sont assujetties aux memes exigences de localisation que les donnees operationnelles. Le ou les sites de sauvegarde sont assujettis aux memes exigences de securite que le site principal, en particulier celles listees aux chapitres 8 et 11. Les communications entre site principal et site de sauvegarde doivent etre protegees par chiffrement, conformement aux exigences du chapitre 10." + } + ], + "Checks": [] + }, + { + "Id": "12.6", + "Description": "Des journaux d'evenements enregistrant les activites des utilisateurs, les exceptions, les defaillances et les evenements de securite de l'information doivent etre crees, tenus a jour et regulierement revus.", + "Name": "Journalisation des evenements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "actiontrail", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de journalisation incluant au minimum les elements suivants : la liste des sources de collecte ; la liste des evenements a journaliser par source ; l'objet de la journalisation par evenement ; la frequence de la collecte et base de temps utilisee ; la duree de retention locale et centralisee ; les mesures de protection des journaux (dont chiffrement et duplication) ; la localisation des journaux. b) Le prestataire doit generer et collecter les evenements suivants : les activites des utilisateurs liees a la securite de l'information ; la modification des droits d'acces dans le perimetre de sa responsabilite ; les evenements issus des mecanismes de lutte contre les codes malveillants (voir chapitre 12.4) ; les exceptions ; les defaillances ; tout autre evenement lie a la securite de l'information. c) Le prestataire doit conserver les evenements issus de la journalisation pendant une duree minimale de six mois sous reserve du respect des exigences legales et reglementaires. d) Le prestataire doit fournir, sur demande d'un commanditaire, l'ensemble des evenements le concernant. e) Il est recommande que le systeme de journalisation mis en place par le prestataire respecte les recommandations de [NT_JOURNAL]." + } + ], + "Checks": [ + "actiontrail_multi_region_enabled", + "oss_bucket_logging_enabled", + "rds_instance_sql_audit_enabled", + "rds_instance_sql_audit_retention", + "sls_logstore_retention_period" + ] + }, + { + "Id": "12.7", + "Description": "Les moyens de journalisation et les informations journalisees doivent etre proteges contre les risques de falsification et les acces non autorises.", + "Name": "Protection de l'information journalisee", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "actiontrail", + "Type": "Automated", + "Comment": "a) Le prestataire doit proteger les equipements de journalisation et les evenements journalises contre les atteintes a leur disponibilite, integrite ou confidentialite, conformement au chapitre 3.2 de [NT_JOURNAL]. b) Le prestataire doit gerer le dimensionnement de l'espace de stockage de l'ensemble des equipements hebergeant une ou plusieurs sources de collecte afin de permettre la conservation locale des evenements journalises prevue par la politique de journalisation des evenements. Cette gestion du dimensionnement doit prendre en compte les evolutions du systeme d'information. c) Le prestataire doit transferer les evenements journalises en assurant leur protection en confidentialite et en integrite, sur un ou plusieurs serveurs centraux dedies et doit les stocker sur une machine physique distincte de celle qui les a generes. d) Le prestataire doit mettre en place une sauvegarde des evenements collectes suivant une politique adaptee. e) Le prestataire doit executer les processus de journalisation et de collecte des evenements avec des comptes disposant de privileges necessaires et suffisants et doit limiter l'acces aux evenements journalises conformement a la politique de controle d'acces (voir chapitre 9.1)." + } + ], + "Checks": [ + "actiontrail_oss_bucket_not_publicly_accessible", + "oss_bucket_not_publicly_accessible", + "sls_logstore_retention_period" + ] + }, + { + "Id": "12.8", + "Description": "Les horloges de tous les systemes de traitement de l'information pertinents d'un organisme ou d'un domaine de securite doivent etre synchronisees sur une source de reference temporelle unique.", + "Name": "Synchronisation des horloges", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une synchronisation des horloges de l'ensemble des equipements sur une ou plusieurs sources de temps internes coherentes entre elles. Ces sources pourront elles-memes etre synchronisees sur plusieurs sources fiables externes, sauf pour les reseaux isoles. b) Le prestataire doit mettre en place l'horodatage de chaque evenement journalise." + } + ], + "Checks": [] + }, + { + "Id": "12.9", + "Description": "Les evenements de securite doivent etre analyses et correles afin de detecter les incidents de securite. Des systemes de detection et de correlation doivent etre mis en oeuvre.", + "Name": "Analyse et correlation des evenements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "sls", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une infrastructure permettant l'analyse et la correlation des evenements enregistres par le systeme de journalisation afin de detecter les evenements susceptibles d'affecter la securite du systeme d'information du service, en temps reel ou a posteriori pour des evenements remontant jusqu'a six mois. b) Il est recommande de s'appuyer sur le referentiel d'exigences des prestataires de detection d'incidents de securite [PDIS] pour la mise en place et l'exploitation de l'infrastructure d'analyse et de correlation des evenements. c) Le prestataire doit acquitter les alarmes remontees par l'infrastructure d'analyse et de correlation des evenements au moins quotidiennement." + } + ], + "Checks": [ + "sls_root_account_usage_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_security_group_changes_alert_enabled", + "sls_vpc_changes_alert_enabled", + "sls_vpc_network_route_changes_alert_enabled", + "sls_cloud_firewall_changes_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_customer_created_cmk_changes_alert_enabled", + "securitycenter_advanced_or_enterprise_edition" + ] + }, + { + "Id": "12.10", + "Description": "Des regles regissant l'installation de logiciels par les utilisateurs doivent etre etablies et mises en oeuvre. Les systemes doivent etre geres de maniere centralisee et les correctifs appliques regulierement.", + "Name": "Installation de logiciels sur des systemes en exploitation", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "ecs", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de controler l'installation de logiciels sur les equipements du systeme d'information du service. b) Le prestataire doit documenter et mettre en oeuvre une procedure de gestion de la configuration des environnements logiciels mis a la disposition du commanditaire, notamment pour leur maintien en condition de securite. c) Le prestataire doit fournir une capacite d'inspection et de suppression, si necessaire, des entrants (controle de l'authenticite et de l'innocuite des mises a jour, controle de l'innocuite des outils fournis, etc.) relatifs au perimetre de l'infrastructure technique : cette capacite d'inspection et de suppression doit generer des journaux d'activite et doit pouvoir faire l'objet d'un audit de code ; les entrants doivent etre traites sur des dispositifs specifiques operes et maintenus par le prestataire et heberges dans une zone cloisonnee du reste de l'infrastructure (du type zone demilitarisee telle que definie dans [G_INT])." + } + ], + "Checks": [ + "ecs_instance_latest_os_patches_applied", + "ecs_instance_endpoint_protection_installed" + ] + }, + { + "Id": "12.11", + "Description": "Les informations sur les vulnerabilites techniques des systemes d'information utilises doivent etre obtenues en temps voulu, l'exposition du prestataire a ces vulnerabilites doit etre evaluee et les mesures appropriees doivent etre prises pour traiter le risque associe.", + "Name": "Gestion des vulnerabilites techniques", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "securitycenter", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus de veille permettant de gerer les vulnerabilites techniques des logiciels et des systemes utilises dans le systeme d'information du service. b) Le prestataire doit evaluer son exposition a ces vulnerabilites en les incluant dans l'appreciation des risques et appliquer les mesures de traitement du risque adaptees." + } + ], + "Checks": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_advanced_or_enterprise_edition", + "ecs_instance_latest_os_patches_applied" + ] + }, + { + "Id": "12.12", + "Description": "L'administration des systemes d'information du service cloud doit etre effectuee de maniere securisee via des canaux dedies et des protocoles securises.", + "Name": "Administration", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "ecs", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure obligeant les administrateurs sous sa responsabilite a utiliser des terminaux dedies pour la realisation exclusive des taches d'administration, en accord avec le chapitre 4.1 intitule 'poste et reseau d'administration' de [NT_ADMIN]. Il doit les maitriser et les maintenir a jour. b) Le prestataire doit mettre en place des mesures de durcissement de la configuration des terminaux utilises pour les taches d'administration, notamment celles du chapitre 4.2 intitule 'securisation du socle' de [NT_ADMIN]. c) Lorsque le prestataire autorise une situation de mobilite pour les administrateurs sous sa responsabilite, il doit l'encadrer par une politique documentee. La solution mise en oeuvre doit assurer que le niveau de securite de cette situation de mobilite est au moins equivalent au niveau de securite hors situation de mobilite (voir chapitres 9.6 et 9.7). Cette solution doit notamment inclure : l'utilisation d'un tunnel chiffre, non debrayable et non contournable, pour l'ensemble des flux (voir chapitre 10.2) ; le chiffrement integral du disque (voir chapitre 10.1)." + } + ], + "Checks": [ + "cs_kubernetes_private_cluster_enabled", + "ram_user_mfa_enabled_console_access" + ] + }, + { + "Id": "12.13", + "Description": "Le telediagnostic et la telemaintenance des composants de l'infrastructure doivent etre encadres par des procedures de securite specifiques.", + "Name": "Telediagnostic et telemaintenance des composants de l'infrastructure", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Dans le cadre du telediagnostic ou de la telemaintenance de composants de l'infrastructure, considerant les risques d'atteinte a la confidentialite des donnees des commanditaires, le prestataire doit : verifier que la personne a qui l'acces doit etre autorise a satisfait aux verifications de l'exigence 7.1.b ; dans le cas d'une intervention realisee par une personne n'ayant pas satisfait aux verifications de l'exigence 7.1.b, mettre en oeuvre une passerelle securisee (poste de rebond) par laquelle la personne devra se connecter et permettant une supervision des actions (autorisation ou interdiction des actions, demande d'explications, etc.) en temps reel, par une personne ayant elle-meme satisfait aux verifications de l'exigence 7.1.b. La passerelle securisee devra repondre aux objectifs de securite specifies dans [G_EXT] ; considerer les actions menees, une fois l'acces autorise, comme des actions d'administration et les journaliser comme telles ; supprimer l'autorisation d'acces a l'issue de l'intervention." + } + ], + "Checks": [] + }, + { + "Id": "12.14", + "Description": "Les flux sortants de l'infrastructure du service cloud doivent etre surveilles afin de detecter et de prevenir les exfiltrations de donnees et les communications non autorisees.", + "Name": "Surveillance des flux sortants de l'infrastructure", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "vpc", + "Type": "Automated", + "Comment": "a) Le prestataire doit fournir une capacite d'inspection et de suppression des sortants de l'infrastructure technique relatifs au perimetre du service (informations de facturation, les eventuels journaux necessaires au traitement d'incidents, etc.) : les sortants doivent pouvoir etre expurges des donnees pouvant porter atteinte a la confidentialite des donnees des commanditaires ; cette capacite d'inspection et de suppression doit generer des journaux d'activite et doit pouvoir faire l'objet d'un audit de code ; les sortants sont traites sur des dispositifs specifiques operes et maintenus par le prestataire, et heberges dans une zone cloisonnee du reste de l'infrastructure (du type zone demilitarisee telle que definie dans [G_INT])." + } + ], + "Checks": [ + "vpc_flow_logs_enabled" + ] + }, + { + "Id": "13.1", + "Description": "Le prestataire doit etablir et maintenir une cartographie complete et a jour de son systeme d'information, incluant les reseaux, les flux et les composants.", + "Name": "Cartographie du systeme d'information", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "actiontrail", + "Type": "Automated", + "Comment": "a) Le prestataire doit etablir et tenir a jour une cartographie du systeme d'information du service, en lien avec l'inventaire des actifs (voir chapitre 8.1), comprenant au minimum les elements suivants : la liste des ressources materielles ou virtualisees ; les noms et fonctions des applications, supportant le service ; le schema d'architecture reseau au niveau 3 du modele OSI sur lequel les points nevralgiques sont identifies : les points d'interconnexions, notamment avec les reseaux tiers et publics ; les reseaux, sous-reseaux, notamment les reseaux d'administration ; les equipements assurant des fonctions de securite (filtrage, authentification, chiffrement, etc.) ; les serveurs hebergeant des donnees ou assurant des fonctions sensibles ; la matrice des flux reseau autorises en precisant : leur description technique (services, protocoles et ports) ; la justification metier ou d'infrastructure technique ; le cas echeant, lorsque des services, protocoles ou ports reputes non surs sont utilises, les mesures compensatoires mises en place, dans la logique de defense en profondeur. b) Le prestataire doit reviser au moins annuellement la cartographie." + } + ], + "Checks": [ + "actiontrail_multi_region_enabled", + "vpc_flow_logs_enabled", + "securitycenter_all_assets_agent_installed" + ] + }, + { + "Id": "13.2", + "Description": "Les reseaux doivent etre cloisonnes et les flux entre les segments doivent etre filtres selon le principe du moindre privilege. Les groupes de securite et les listes de controle d'acces reseau doivent etre configures de maniere restrictive.", + "Name": "Cloisonnement des reseaux", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "ecs", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre, pour le systeme d'information du service, les mesures de cloisonnement (logique, physique ou par chiffrement) pour separer les flux reseau selon : la sensibilite des informations transmises ; la nature des flux (production, administration, supervision, etc.) ; le domaine d'appartenance des flux (des commanditaires - avec distinction par commanditaire ou ensemble de commanditaires, du prestataire, des tiers, etc.) ; le domaine technique (traitement, stockage, etc.). b) Le prestataire doit cloisonner, physiquement ou par chiffrement, tous les flux de donnees internes au systeme d'information du service vis-a-vis de tout autre systeme d'information. Lorsque ce cloisonnement est realise par chiffrement, il est realise en accord avec les exigences du chapitre 10.2. c) Dans le cas ou le reseau d'administration de l'infrastructure technique ne fait pas l'objet d'un cloisonnement physique, les flux d'administration doivent transiter dans un tunnel chiffre, en accord avec les exigences du chapitre 10.2. d) Le prestataire doit mettre en place et configurer un pare-feu applicatif pour proteger les interfaces d'administration destinees a ses commanditaires et exposees sur un reseau public. e) Le prestataire doit mettre en oeuvre sur l'ensemble des interfaces d'administration et de supervision de l'infrastructure technique du service un mecanisme de filtrage n'autorisant que les connexions legitimes identifiees dans la matrice des flux autorises." + } + ], + "Checks": [ + "ecs_securitygroup_restrict_rdp_internet", + "ecs_securitygroup_restrict_ssh_internet", + "ecs_instance_no_legacy_network", + "cs_kubernetes_network_policy_enabled", + "cs_kubernetes_private_cluster_enabled", + "rds_instance_no_public_access_whitelist" + ] + }, + { + "Id": "13.3", + "Description": "Les reseaux doivent etre surveilles de maniere continue afin de detecter les activites anormales ou malveillantes.", + "Name": "Surveillance des reseaux", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "securitycenter", + "Type": "Automated", + "Comment": "a) Le prestataire doit disposer une ou plusieurs sondes de detection d'incidents de securite sur le systeme d'information du service. Ces sondes doivent notamment permettre la supervision de chacune des interconnexions du systeme d'information du service avec des systemes d'information tiers et des reseaux publics. Ces sondes doivent etre des sources de collecte pour l'infrastructure d'analyse et de correlation des evenements (voir chapitre 12.9)." + } + ], + "Checks": [ + "securitycenter_advanced_or_enterprise_edition", + "vpc_flow_logs_enabled" + ] + }, + { + "Id": "14.1", + "Description": "Des regles de developpement securise des logiciels et des systemes doivent etre etablies et appliquees au sein du prestataire.", + "Name": "Politique de developpement securise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des regles de developpement securise des logiciels et des systemes, et les appliquer aux developpements internes. b) Le prestataire doit documenter et mettre en oeuvre une formation adaptee en developpement securise aux employes concernes." + } + ], + "Checks": [] + }, + { + "Id": "14.2", + "Description": "Les changements apportes aux systemes dans le cycle de developpement doivent etre geres a l'aide de procedures formelles de controle des changements.", + "Name": "Procedures de controle des changements de systeme", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "actiontrail", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de controle des changements apportes au systeme d'information du service. b) Le prestataire doit documenter et mettre en oeuvre une procedure de validation des changements apportes au systeme d'information du service sur un environnement de pre-production avant leur mise en production. c) Le prestataire doit conserver un historique des versions des logiciels et des systemes (developpements internes ou externes, produits commerciaux) mis en oeuvre pour permettre de reconstituer, le cas echeant dans un environnement de test, un environnement complet tel qu'il etait mis en oeuvre a une date donnee. La duree de conservation de cet historique doit etre en accord avec celle des sauvegardes (voir chapitre 12.5)." + } + ], + "Checks": [ + "actiontrail_multi_region_enabled" + ] + }, + { + "Id": "14.3", + "Description": "Lorsque les plateformes d'exploitation sont modifiees, les applications critiques metier doivent etre revues et testees afin de verifier qu'il n'y a pas d'effet indesirable sur l'activite ou la securite du prestataire.", + "Name": "Revue technique des applications apres changement apporte a la plateforme d'exploitation", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester, prealablement a leur mise en production, l'ensemble des applications afin de verifier l'absence de tout effet indesirable sur l'activite ou sur la securite du service." + } + ], + "Checks": [] + }, + { + "Id": "14.4", + "Description": "Les environnements de developpement doivent etre securises et isoles des environnements de production.", + "Name": "Environnement de developpement securise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit mettre en oeuvre un environnement securise de developpement permettant de gerer l'integralite du cycle de developpement du systeme d'information du service. b) Le prestataire doit prendre en compte les environnements de developpement dans l'appreciation des risques et en assurer la protection conformement au present referentiel." + } + ], + "Checks": [] + }, + { + "Id": "14.5", + "Description": "Le prestataire doit superviser et surveiller l'activite de developpement externalise du systeme.", + "Name": "Developpement externalise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de superviser et de controler l'activite de developpement externalise des logiciels et des systemes. Cette procedure doit s'assurer que l'activite de developpement externalise soit conforme a la politique de developpement securise du prestataire et permette d'atteindre un niveau de securite du developpement externe equivalent a celui d'un developpement interne (voir exigence 14.1 a))." + } + ], + "Checks": [] + }, + { + "Id": "14.6", + "Description": "Des tests de securite et de conformite doivent etre effectues tout au long du cycle de developpement et apres chaque changement significatif.", + "Name": "Test de la securite et conformite du systeme", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "securitycenter", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit soumettre les systemes d'information, nouveaux ou mis a jour, a des tests de conformite et de fonctionnalite de securite pendant le developpement. Il doit documenter et mettre en oeuvre une procedure de test qui identifie : les taches a realiser ; les donnees d'entree ; les resultats attendus en sortie." + } + ], + "Checks": [ + "securitycenter_vulnerability_scan_enabled", + "cs_kubernetes_cluster_check_weekly" + ] + }, + { + "Id": "14.7", + "Description": "Les donnees de test doivent etre soigneusement selectionnees, protegees et controlees.", + "Name": "Protection des donnees de test", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'assurer l'integrite des donnees de tests utilises en pre-production. b) Si le prestataire souhaite utiliser des donnees du commanditaire issues de la production pour realiser des tests, le prestataire doit prealablement obtenir l'accord du commanditaire et les anonymiser. Le prestataire doit assurer la confidentialite des donnees lors de leur anonymisation." + } + ], + "Checks": [] + }, + { + "Id": "15.1", + "Description": "Le prestataire doit identifier les tiers ayant acces a l'information ou aux moyens de traitement de l'information et evaluer les risques associes.", + "Name": "Identification des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit tenir a jour une liste exhaustive des tiers participant a la mise en oeuvre du service (hebergeur, developpeur, integrateur, archiveur, sous-traitant operant sur site ou a distance, fournisseurs de climatisation, etc.). Cette liste doit preciser la contribution du tiers au service et au traitement des donnees a caractere personnel. Elle doit tenir compte des cas de sous-traitance a plusieurs niveaux. b) Le prestataire doit tenir a disposition du commanditaire la liste de l'ensemble des tiers qui peuvent acceder aux donnees et l'informer de tout changement de sous-traitants au sens de l'article 28 du [RGPD] afin que le commanditaire puisse emettre des objections a cet egard." + } + ], + "Checks": [] + }, + { + "Id": "15.2", + "Description": "Tous les aspects pertinents de la securite de l'information doivent etre traites dans les accords conclus avec les tiers.", + "Name": "La securite dans les accords conclus avec les tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit exiger des tiers participant a la mise en oeuvre du service, dans leur contribution au service, un niveau de securite au moins equivalent a celui qu'il s'engage a maintenir dans sa propre politique de securite. Il doit le faire au travers d'exigences, adaptees a chaque tiers et a sa contribution au service, dans les cahiers des charges ou dans les clauses de securite des accords de partenariat. Le prestataire doit inclure ces exigences dans les contrats conclus avec les tiers. b) Le prestataire doit contractualiser, avec chacun des tiers participant a la mise en oeuvre du service, des clauses d'audit permettant a un organisme de qualification de verifier que ces tiers respectent les exigences du present referentiel. c) Le prestataire doit definir et attribuer les roles et les responsabilites relatives a la modification ou a la fin du contrat le liant a un tiers participant a la mise en oeuvre du service." + } + ], + "Checks": [] + }, + { + "Id": "15.3", + "Description": "Le prestataire doit surveiller, revoir et auditer a intervalles reguliers la prestation des services des tiers.", + "Name": "Surveillance et revue des services des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de controler regulierement les mesures mises en place par les tiers participant a la mise en oeuvre du service pour respecter les exigences du present referentiel, conformement au chapitre 18.3." + } + ], + "Checks": [] + }, + { + "Id": "15.4", + "Description": "Les changements dans les services des tiers, incluant le maintien et l'amelioration des politiques, procedures et mesures existantes de securite de l'information, doivent etre geres.", + "Name": "Gestion des changements apportes dans les services des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de suivi des changements apportes par les tiers participant a la mise en oeuvre du service susceptibles d'affecter le niveau de securite du systeme d'information du service. b) Dans la mesure ou un changement de tiers participant a la mise en oeuvre du service affecte le niveau de securite du service, le prestataire doit en informer l'ensemble des commanditaires sans delais conformement au chapitre 12.2 et mettre en oeuvre les mesures permettant de retablir le niveau de securite precedent." + } + ], + "Checks": [] + }, + { + "Id": "15.5", + "Description": "Les personnes intervenant dans le cadre du service cloud doivent etre soumises a des engagements de confidentialite.", + "Name": "Engagements de confidentialite", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de reviser au moins annuellement les exigences en matiere d'engagements de confidentialite ou de non-divulgation vis-a-vis des tiers participant a la mise en oeuvre du service." + } + ], + "Checks": [] + }, + { + "Id": "16.1", + "Description": "Des responsabilites et des procedures de gestion doivent etre etablies pour garantir une reponse rapide, efficace et ordonnee aux incidents lies a la securite de l'information.", + "Name": "Responsabilites et procedures", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'apporter des reponses rapides et efficaces aux incidents de securite. Ces procedures doivent definir les moyens et delais de communication des incidents de securite a l'ensemble des commanditaires concernes ainsi que le niveau de confidentialite exige pour cette communication. b) Le prestataire doit informer ses employes et l'ensemble des tiers participant a la mise en oeuvre du service de cette procedure. c) Le prestataire doit documenter toute violation de donnees a caractere personnel et en informer son commanditaire. La violation doit etre notifiee a la CNIL si elle presente un risque pour les droits et libertes des personnes concernees. Elle doit faire l'objet d'une information aupres des personnes concernees lorsque le risque pour leur vie privee est eleve." + } + ], + "Checks": [] + }, + { + "Id": "16.2", + "Description": "Les evenements lies a la securite de l'information doivent etre signales dans les meilleurs delais par les voies hierarchiques appropriees. Des mecanismes de detection et de notification automatises doivent etre mis en oeuvre.", + "Name": "Signalements lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "securitycenter", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure exigeant de ses employes et des tiers participant a la mise en oeuvre du service qu'ils lui rendent compte de tout incident de securite, avere ou suspecte ainsi que de toute faille de securite. b) Le prestataire doit documenter et mettre en oeuvre une procedure permettant a l'ensemble des commanditaires de signaler tout incident de securite, avere ou suspecte et toute faille de securite. c) Le prestataire doit communiquer sans delai aux commanditaires les incidents de securite et les preconisations associees pour en limiter les impacts. Il doit permettre au commanditaire de choisir les niveaux de gravite des incidents pour lesquels il souhaite etre informe. d) Le prestataire doit communiquer les incidents de securite aux autorites competentes conformement aux exigences legales et reglementaires en vigueur." + } + ], + "Checks": [ + "securitycenter_notification_enabled_high_risk", + "securitycenter_advanced_or_enterprise_edition", + "sls_unauthorized_api_calls_alert_enabled" + ] + }, + { + "Id": "16.3", + "Description": "Les evenements lies a la securite de l'information doivent etre apprecies et il doit etre decide s'il est necessaire de les classer comme incidents lies a la securite de l'information.", + "Name": "Appreciation des evenements et prise de decision", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "securitycenter", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit apprecier les evenements lies a la securite de l'information et decider s'il faut les qualifier en incidents de securite. Pour l'appreciation, il doit s'appuyer sur une ou plusieurs echelles (estimation, evaluation, etc.) partagees avec le commanditaire. Note : Les incidents de securite incluent les violations de donnees a caractere personnel. b) Le prestataire doit utiliser une classification permettant d'identifier clairement les incidents de securite touchant des donnees relatives aux commanditaires, conformement aux resultats de l'appreciation des risques. Cette classification doit inclure les violations de donnees a caractere personnel." + } + ], + "Checks": [ + "securitycenter_notification_enabled_high_risk" + ] + }, + { + "Id": "16.4", + "Description": "Les incidents lies a la securite de l'information doivent etre traites conformement aux procedures documentees.", + "Name": "Reponse aux incidents lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit traiter les incidents de securite jusqu'a leur resolution et doit informer les commanditaires conformement aux procedures." + } + ], + "Checks": [] + }, + { + "Id": "16.5", + "Description": "Les connaissances acquises lors de l'analyse et du traitement des incidents lies a la securite de l'information doivent etre exploitees pour reduire la probabilite ou l'impact d'incidents futurs.", + "Name": "Tirer des enseignements des incidents lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus d'amelioration continue afin de diminuer l'occurrence et l'impact de types d'incidents de securite deja traites." + } + ], + "Checks": [] + }, + { + "Id": "16.6", + "Description": "Le prestataire doit definir et appliquer des procedures pour l'identification, le recueil, l'acquisition et la preservation de preuves. Les journaux d'audit doivent etre proteges et valides.", + "Name": "Recueil de preuves", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "actiontrail", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'enregistrer les informations relatives aux incidents de securite et pouvant servir d'elements de preuve." + } + ], + "Checks": [ + "actiontrail_multi_region_enabled", + "actiontrail_oss_bucket_not_publicly_accessible" + ] + }, + { + "Id": "17.1", + "Description": "Le prestataire doit determiner ses exigences en matiere de securite de l'information et de continuite du management de la securite de l'information dans des situations defavorables, par exemple lors d'une crise ou d'un sinistre.", + "Name": "Organisation de la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre oeuvre un plan de continuite d'activite prenant en compte la securite de l'information. b) Le prestataire doit reviser annuellement le plan de continuite d'activite du service et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "17.2", + "Description": "Le prestataire doit etablir, documenter, mettre en oeuvre et maintenir des processus, des procedures et des mesures de controle pour assurer le niveau requis de continuite de la securite de l'information au cours d'une situation defavorable. Les services doivent etre deployes en multi-AZ.", + "Name": "Mise en oeuvre de la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des procedures permettant de maintenir ou de restaurer l'exploitation du service et d'assurer la disponibilite des informations au niveau et dans les delais pour lesquels le prestataire s'est engage vis-a-vis du commanditaire dans la convention de service." + } + ], + "Checks": [] + }, + { + "Id": "17.3", + "Description": "Le prestataire doit verifier a intervalles reguliers les mesures de continuite de la securite de l'information mises en oeuvre afin de s'assurer qu'elles sont valables et efficaces dans des situations defavorables.", + "Name": "Verifier, revoir et evaluer la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester le plan de continuite d'activites afin de s'assurer qu'il est pertinent et efficace en situation de crise." + } + ], + "Checks": [] + }, + { + "Id": "17.4", + "Description": "Les moyens de traitement de l'information doivent etre mis en oeuvre avec suffisamment de redondance pour repondre aux exigences de disponibilite. Les mecanismes de protection contre la suppression accidentelle doivent etre actives.", + "Name": "Disponibilite des moyens de traitement de l'information", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures qui lui permettent de repondre au besoin de disponibilite du service defini dans la convention de service (voir chapitre 19.1)." + } + ], + "Checks": [] + }, + { + "Id": "17.5", + "Description": "La configuration de l'infrastructure technique du service cloud doit etre sauvegardee regulierement afin de permettre sa restauration en cas de sinistre.", + "Name": "Sauvegarde de la configuration de l'infrastructure technique", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "actiontrail", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de sauvegarde hors-ligne de la configuration de l'infrastructure technique." + } + ], + "Checks": [ + "actiontrail_multi_region_enabled", + "securitycenter_all_assets_agent_installed" + ] + }, + { + "Id": "17.6", + "Description": "Le prestataire doit mettre a disposition du commanditaire un dispositif de sauvegarde de ses donnees, permettant la restauration en cas de sinistre.", + "Name": "Mise a disposition d'un dispositif de sauvegarde des donnees du commanditaire", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre a disposition du commanditaire un service de sauvegarde de ses donnees." + } + ], + "Checks": [] + }, + { + "Id": "18.1", + "Description": "Toutes les exigences legales, reglementaires et contractuelles en vigueur, ainsi que l'approche du prestataire pour satisfaire ces exigences, doivent etre explicitement definies, documentees et tenues a jour pour chaque systeme d'information et pour le prestataire.", + "Name": "Identification de la legislation et des exigences contractuelles applicables", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les exigences legales, reglementaires et contractuelles en vigueur applicables au service. En France, le prestataire doit considerer au minimum les textes suivants : les donnees a caractere personnel [LOI_IL], [RGPD] ; le secret professionnel [CP_ART_226_13], le cas echeant sans prejudice de l'application de l'article 40 alinea 2 du Code de procedure penale relatif au signalement a une autorite judiciaire ; l'abus de confiance [CP_ART_314-1] ; le secret des correspondances privees [CP_ART_226-15] ; l'atteinte a la vie privee [CP_ART_226-1] ; l'acces ou le maintien frauduleux a un systeme d'information [CP_ART_323-1]. b) Le prestataire doit, selon son role dans les traitements de donnees a caractere personnel (responsable de traitement, sous-traitant ou co-responsable) justifier et documenter les choix de mesures techniques et organisationnelles realises en vue de repondre aux exigences de protection des donnees a caractere personnel du present referentiel (voir partie 19.5). c) Le prestataire doit documenter et mettre en oeuvre les procedures permettant de respecter les exigences legales, reglementaires et contractuelles en vigueur applicables au service, ainsi que les besoins de securite specifiques (voir exigence 8.3b)). d) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible l'ensemble de ces procedures. e) Le prestataire doit documenter et mettre en oeuvre un processus de veille actif des exigences legales, reglementaires et contractuelles en vigueur applicables au service." + } + ], + "Checks": [] + }, + { + "Id": "18.2", + "Description": "L'approche du prestataire vis-a-vis de la gestion de la securite de l'information et sa mise en oeuvre (c'est-a-dire les objectifs de controle, les mesures, les politiques, les procedures et les processus relatifs a la securite de l'information) doivent etre revues de maniere independante a intervalles definis ou en cas de changement significatif.", + "Name": "Revue independante de la securite de l'information", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un programme d'audit sur trois ans definissant le perimetre et la frequence des audits en accord avec la gestion du changement, les politiques, et les resultats de l'appreciation des risques. Le prestataire doit inclure dans le programme d'audit un audit qualifie par an realise par un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie. L'ensemble du programme d'audit doit notamment couvrir : l'audit de la configuration de l'infrastructure technique du service (par echantillonnage et doit inclure tous types d'equipements et de serveurs presents dans le systeme d'information du service) ; le test d'intrusion des interfaces d'administration exposees sur un reseau public ; le test d'intrusion de l'interface utilisateur pour les services SaaS ; si le service beneficie de developpements internes, l'audit de code source portant sur les fonctionnalites de securite implementees (l'approche en continue doit etre privilegiee). b) Il est recommande que le prestataire mette en oeuvre des mecanismes automatises d'audit de la configuration adaptes a l'infrastructure technique du service." + } + ], + "Checks": [] + }, + { + "Id": "18.3", + "Description": "Les responsables doivent regulierement s'assurer de la conformite du traitement de l'information et des procedures au sein de leur domaine de responsabilite, au regard des politiques et des normes de securite.", + "Name": "Conformite avec les politiques et les normes de securite", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "securitycenter", + "Type": "Partially Automated", + "Comment": "a) Le prestataire via le responsable de la securite de l'information doit s'assurer regulierement de l'execution correcte de l'ensemble des procedures de securite placees sous sa responsabilite en vue de garantir leur conformite avec les politiques et normes de securite." + } + ], + "Checks": [ + "securitycenter_advanced_or_enterprise_edition", + "actiontrail_multi_region_enabled" + ] + }, + { + "Id": "18.4", + "Description": "Les systemes d'information doivent etre examines regulierement quant a leur conformite avec les politiques et les normes de securite de l'information du prestataire.", + "Name": "Examen de la conformite technique", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "securitycenter", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique permettant de verifier la conformite technique du service aux exigences du present referentiel. Cette politique doit definir les objectifs, methodes, frequences, resultats attendus et mesures correctrices." + } + ], + "Checks": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_advanced_or_enterprise_edition" + ] + }, + { + "Id": "19.1", + "Description": "Le prestataire doit etablir une convention de service avec le commanditaire definissant les engagements de niveau de service, les responsabilites et les conditions d'utilisation du service cloud.", + "Name": "Convention de service", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit etablir une convention de service avec chacun des commanditaires du service. Toute modification de la convention de service doit etre soumise a acceptation du commanditaire. b) Le prestataire doit identifier dans la convention de service : les obligations, droits et responsabilites de chacune des parties : prestataire et tiers impliques dans la fourniture du service, commanditaires, etc. ; les elements explicitement exclus des responsabilites du prestataire dans la limite de ce que prevoient les exigences legales et reglementaires en vigueur, notamment l'article 28 du [RGPD] ; la localisation du service. La localisation du support doit etre precisee lorsqu'il est realise depuis un Etat hors l'Union Europeenne, comme le permet l'exigence 19.2.e. c) Le prestataire doit proposer une convention de service appliquant le droit d'un Etat membre de l'Union Europeenne. Le droit applicable doit etre identifie dans la convention de service. d) La convention de service doit indiquer que la collecte, la manipulation, le stockage, et plus generalement le traitement des donnees faits dans le cadre de l'avant-vente, de la mise en oeuvre, de la maintenance et l'arret du service sont realises conformement aux exigences edictees par la legislation en vigueur. e) La convention de service doit indiquer que le prestataire doit mettre a la disposition du commanditaire, sur demande de celui-ci, les elements d'appreciation des risques lies a la soumission des donnees du commanditaire au droit d'un etat non-membre de l'Union Europeenne (voir 5.3.e). f) Le prestataire doit decrire dans la convention de service les moyens techniques et organisationnels qu'il met en oeuvre pour assurer le respect du droit applicable. g) Le prestataire doit inclure dans la convention de service une clause de revision de la convention prevoyant notamment une resiliation sans penalite pour le commanditaire en cas de perte de la qualification octroyee au service. h) Le prestataire doit inclure dans la convention de service une clause de reversibilite permettant au commanditaire de recuperer l'ensemble de ses donnees (fournies directement par le commanditaire ou produites dans le cadre du service a partir des donnees ou des actions du commanditaire). i) Le prestataire doit assurer cette reversibilite via l'une des modalites techniques suivantes : la mise a disposition de fichiers suivant un ou plusieurs formats documentes et exploitables en dehors du service fourni par le prestataire ; la mise en place d'interfaces techniques permettant l'acces aux donnees suivant un schema documente et exploitable (API, format pivot, etc.). Les modalites techniques de la reversibilite figurent dans la convention de service. j) Le prestataire doit indiquer dans la convention de service le niveau de disponibilite du service. k) Le prestataire doit indiquer dans la convention de service qu'il ne peut disposer des donnees transmises et generees par le commanditaire, leur disposition etant reservee au commanditaire. l) Le prestataire doit indiquer dans la convention de service qu'il ne divulgue aucune information relative a la prestation a des tiers, sauf autorisation formelle et ecrite du commanditaire. m) Le prestataire doit indiquer dans la convention de service si les donnees du commanditaire sont automatiquement sauvegardees ou non. Dans la negative, le prestataire doit sensibiliser le commanditaire aux risques encourus et clairement indiquer les operations a mener par le commanditaire pour que ses donnees soient sauvegardees. n) Le prestataire doit indiquer dans la convention de service s'il autorise l'acces distant pour des actions d'administration ou de support au systeme d'information du service. o) Le prestataire doit preciser dans la convention de service que : le service est qualifie et inclure l'attestation de qualification ; le commanditaire peut deposer une reclamation relative au service qualifie aupres de l'ANSSI ; le commanditaire autorise l'ANSSI et l'organisme de qualification a auditer le service et son systeme d'information du service afin de verifier qu'ils respectent les exigences du present referentiel. p) Le prestataire doit preciser dans la convention de service que le commanditaire autorise, conformement au present referentiel (voir chapitre 18.2, un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie mandate par le prestataire a auditer le service et son systeme d'information dans le cadre du plan de controle. q) Le prestataire doit preciser dans la convention de service qu'il s'engage a mettre a disposition toutes les informations necessaires a la realisation d'audits de conformite aux dispositions de l'article 28 du [RGPD], menes par le commanditaire ou un tiers mandate. r) Il est recommande que le tiers mandate pour les audits soit un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie." + } + ], + "Checks": [] + }, + { + "Id": "19.2", + "Description": "Les donnees du commanditaire doivent etre stockees et traitees dans des centres de donnees situes sur le territoire de l'Union europeenne. Les politiques de restriction de region doivent etre appliquees.", + "Name": "Localisation des donnees", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et communiquer au commanditaire la localisation du stockage et du traitement des donnees de ce dernier. b) Le prestataire doit stocker et traiter les donnees du commanditaire au sein de l'Union Europeenne. c) Les operations d'administration et de supervision du service doivent etre realisees depuis le territoire de l'Union Europeenne. d) Le prestataire doit stocker et traiter les donnees techniques (identites des beneficiaires et des administrateurs de l'infrastructure technique, donnees manipulees par le Software Defined Network, journaux de l'infrastructure technique, annuaire, certificats, configuration des acces, etc.) au sein de l'Union Europeenne. e) Le prestataire peut realiser des operations de support aux commanditaires depuis un Etat hors de l'Union Europeenne. Il doit documenter la liste des operations qui peuvent etre effectuees par le support au commanditaire depuis un Etat hors de l'Union Europeenne, et les mecanismes permettant d'en assurer le controle d'acces et la supervision depuis l'Union Europeenne." + } + ], + "Checks": [] + }, + { + "Id": "19.3", + "Description": "Les services cloud qualifies SecNumCloud doivent etre operes depuis le territoire de l'Union europeenne.", + "Name": "Regionalisation", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit s'assurer que les interfaces du service accessibles au commanditaire soient au moins disponibles en langue francaise. b) Le prestataire doit fournir un support de premier niveau en langue francaise." + } + ], + "Checks": [] + }, + { + "Id": "19.4", + "Description": "Le prestataire doit definir les conditions de fin de contrat, incluant les modalites de restitution et de suppression des donnees du commanditaire.", + "Name": "Fin de contrat", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) A la fin du contrat liant le prestataire et le commanditaire, que le contrat soit arrive a son terme ou pour toute autre cause, le prestataire doit assurer un effacement securise de l'integralite des donnees du commanditaire. Cet effacement doit faire l'objet d'un preavis formel au commanditaire de la part du prestataire respectant un delai de vingt et un jours calendaires. L'effacement peut etre realise suivant l'une des methodes suivantes, et ce dans un delai precise dans la convention de service : effacement par reecriture complete de tout support ayant heberge ces donnees ; effacement des cles utilisees pour le chiffrement des espaces de stockage du commanditaire decrit au chapitre 10.1 ; recyclage securise, dans les conditions enoncees au chapitre 11.9. b) A la fin du contrat, le prestataire doit supprimer les donnees techniques relatives au commanditaire (annuaire, certificats, configuration des acces, etc.)." + } + ], + "Checks": [] + }, + { + "Id": "19.5", + "Description": "Le prestataire doit mettre en oeuvre des mesures techniques et organisationnelles appropriees pour garantir la protection des donnees a caractere personnel conformement a la reglementation en vigueur.", + "Name": "Protection des donnees a caractere personnel", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit justifier du respect des principes de protection des donnees pour les traitements de donnees a caractere personnel mis en oeuvre pour son propre compte. Il doit justifier au minimum les points suivants : les finalites des traitements determinees, explicites et legitimes ; la tracabilite des activites de traitement pour son compte et celui de son commanditaire ; le fondement licite des traitements ; l'interdiction du detournement de finalite des traitements ; les donnees utilisees respectent le principe du minimum necessaire et suffisant pour les traitements ; ainsi sont adequates, pertinentes et limitees ; la qualite des donnees utilisees pour les traitements maintenue : donnees exactes et tenues a jour ; les durees de conservation definies et limitees. b) Le prestataire doit justifier, pour les traitements de donnees a caractere personnel mis en oeuvre pour son propre compte, du respect des droits des personnes concernees. Il doit justifier au minimum les points suivants : l'information des usagers via un traitement loyal et transparent ; le recueil du consentement des usagers : expres, demontrable et retirable ; la possibilite pour les usagers d'exercer les droits d'acces, de rectification et d'effacement ; la possibilite pour les usagers d'exercer les droits de limitation du traitement, de portabilite et d'opposition. c) Lorsqu'il agit en qualite de sous-traitant au sens de l'article 28 de [RGPD], le prestataire doit apporter assistance et conseil au commanditaire en l'informant si une instruction de ce dernier constitue une violation des regles de protection des donnees." + } + ], + "Checks": [] + }, + { + "Id": "19.6", + "Description": "Le prestataire doit mettre en oeuvre des mesures de protection vis-a-vis du droit extra-europeen, afin de garantir que les donnees du commanditaire ne puissent etre soumises a des legislations extra-europeennes.", + "Name": "Protection vis-a-vis du droit extra-europeen", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le siege statutaire, administration centrale et principal etablissement du prestataire doivent etre etablis au sein d'un Etat membre de l'Union Europeenne. b) Le capital social et les droits de vote dans la societe du prestataire ne doivent pas etre, directement ou indirectement : individuellement detenus a plus de 24% ; et collectivement detenus a plus de 39% ; par des entites tierces possedant leur siege statutaire, administration centrale ou principal etablissement au sein d'un Etat non membre de l'Union europeenne. Ces entites tierces susmentionnees ne peuvent pas individuellement ou collectivement : en vertu d'un contrat ou de clauses statutaires, disposer d'un droit de veto ; en vertu d'un contrat ou de clauses statutaires, designer la majorite des membres des organes d'administration, de direction ou de surveillance du prestataire. c) En cas de recours par le prestataire, dans le cadre des services fournis au commanditaire, aux services d'une societe tierce - y compris un sous-traitant - possedant son siege statutaire, administration centrale ou principal etablissement au sein d'un Etat non membre de l'Union Europeenne ou appartenant ou etant controlee par une societe tierce domiciliee en dehors l'Union Europeenne, cette susdite societe tierce ne doit pas avoir la possibilite technique d'obtenir les donnees operees au travers du service. d) Dans le cadre de l'exigence 19.6.c, toute societe tierce a laquelle le prestataire recourt pour fournir tout ou partie du service rendu au commanditaire, doit garantir au prestataire une autonomie d'exploitation continue dans la fourniture des services d'informatique en nuage qu'il opere ou doit etre qualifie SecNumCloud. e) Le service fourni par le prestataire doit respecter la legislation en vigueur en matiere de droits fondamentaux et les valeurs de l'Union relatives au respect de la dignite humaine, a la liberte, a l'egalite, a la democratie et a l'Etat de droit. f) Le prestataire doit informer formellement le commanditaire, et dans un delai d'un mois, de tout changement juridique, organisationnel ou technique pouvant avoir un impact sur la conformite de la prestation aux exigences du chapitre 19.6." + } + ], + "Checks": [] + } + ] +} diff --git a/prowler/compliance/azure/cis_3.0_azure.json b/prowler/compliance/azure/cis_3.0_azure.json index 3e70009d72..90fa5bfea0 100644 --- a/prowler/compliance/azure/cis_3.0_azure.json +++ b/prowler/compliance/azure/cis_3.0_azure.json @@ -752,7 +752,7 @@ "Id": "2.2.8", "Description": "Ensure Multi-factor Authentication is Required to access Microsoft Admin Portals", "Checks": [ - "defender_ensure_defender_for_server_is_on" + "entra_conditional_access_policy_require_mfa_for_admin_portals" ], "Attributes": [ { diff --git a/prowler/compliance/azure/cis_4.0_azure.json b/prowler/compliance/azure/cis_4.0_azure.json index 0df135a546..3777d24663 100644 --- a/prowler/compliance/azure/cis_4.0_azure.json +++ b/prowler/compliance/azure/cis_4.0_azure.json @@ -1036,7 +1036,7 @@ "Id": "6.2.7", "Description": "Ensure that multifactor authentication is required to access Microsoft Admin Portals", "Checks": [ - "defender_ensure_defender_for_server_is_on" + "entra_conditional_access_policy_require_mfa_for_admin_portals" ], "Attributes": [ { diff --git a/prowler/compliance/azure/cis_5.0_azure.json b/prowler/compliance/azure/cis_5.0_azure.json index 4a7172de77..f786ebd3e2 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -472,7 +472,7 @@ "Id": "5.2.7", "Description": "Ensure that multifactor authentication is required to access Microsoft Admin Portals", "Checks": [ - "defender_ensure_defender_for_server_is_on" + "entra_conditional_access_policy_require_mfa_for_admin_portals" ], "Attributes": [ { diff --git a/prowler/compliance/azure/prowler_threatscore_azure.json b/prowler/compliance/azure/prowler_threatscore_azure.json index 1eabf7d582..aa801887b2 100644 --- a/prowler/compliance/azure/prowler_threatscore_azure.json +++ b/prowler/compliance/azure/prowler_threatscore_azure.json @@ -63,7 +63,7 @@ "Id": "1.1.4", "Description": "Ensure Multi-factor Authentication is Required to access Microsoft Admin Portals", "Checks": [ - "defender_ensure_defender_for_server_is_on" + "entra_conditional_access_policy_require_mfa_for_admin_portals" ], "Attributes": [ { diff --git a/prowler/compliance/azure/secnumcloud_3.2_azure.json b/prowler/compliance/azure/secnumcloud_3.2_azure.json new file mode 100644 index 0000000000..ef8c94113e --- /dev/null +++ b/prowler/compliance/azure/secnumcloud_3.2_azure.json @@ -0,0 +1,1504 @@ +{ + "Framework": "SecNumCloud", + "Name": "SecNumCloud Referentiel d'Exigences v3.2", + "Version": "3.2", + "Provider": "Azure", + "Description": "The SecNumCloud framework is published by ANSSI (Agence Nationale de la Securite des Systemes d'Information) to qualify cloud service providers operating in France. Version 3.2, dated March 8, 2022, covers IaaS, CaaS, PaaS, and SaaS services with requirements spanning information security policies, access control, cryptography, physical security, operational security, communications security, and data sovereignty protections against extra-European law.", + "Requirements": [ + { + "Id": "5.1", + "Description": "Le prestataire doit definir et appliquer des principes de securite de l'information adaptes a ses activites de fourniture de services cloud.", + "Name": "Principes", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit operer la prestation a l'etat de l'art pour le type d'activite retenu : utiliser des logiciels stables beneficiant d'un suivi des correctifs de securite et parametres de facon a obtenir un niveau de securite optimal. b) Le prestataire doit appliquer le guide d'hygiene informatique de l'ANSSI [HYGIENE], niveau renforce, au systeme d'information du service." + } + ], + "Checks": [] + }, + { + "Id": "5.2", + "Description": "Le prestataire doit definir, faire approuver par la direction, publier et communiquer aux salaries et aux tiers concernes un ensemble de politiques de securite de l'information.", + "Name": "Politique de securite de l'information", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de securite de l'information relative au service. b) La politique de securite de l'information doit identifier les engagements du prestataire quant au respect de la legislation et reglementation nationale en vigueur selon la nature des informations qui pourraient etre confiees par le commanditaire au prestataire ; il revient en revanche in fine au commanditaire de s'assurer du respect des contraintes legales et reglementaires applicables aux donnees qu'il confie effectivement au prestataire. c) La politique de securite de l'information doit notamment couvrir les themes abordes aux chapitres 6 a 19 du present referentiel. d) La direction du prestataire doit approuver formellement la politique de securite de l'information. e) Le prestataire doit reviser annuellement la politique de securite de l'information et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "5.3", + "Description": "Le prestataire doit definir et appliquer un processus d'appreciation des risques de securite de l'information.", + "Name": "Appreciation des risques", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter une appreciation des risques couvrant l'ensemble du perimetre du service. b) Le prestataire doit realiser son appreciation de risques en utilisant une methode documentee garantissant la reproductibilite et comparabilite de la demarche. c) Le prestataire doit prendre en compte dans l'appreciation des risques : la gestion d'informations du commanditaire ayant des besoins de securite differents ; les risques ayant des impacts sur les droits et libertes des personnes concernees en cas d'acces non autorise, de modification non desiree et de disparition de donnees a caractere personnel ; les risques de defaillance des mecanismes de cloisonnement des ressources de l'infrastructure technique (memoire, calcul, stockage, reseau) partagees entre les commanditaires ; les risques lies a l'effacement incomplet ou non securise des donnees stockees sur les espaces de memoire ou de stockage partages entre commanditaires, en particulier lors des reallocations des espaces de memoire et de stockage ; les risques lies a l'exposition des interfaces d'administration sur un reseau public ; les risques d'atteinte a la confidentialite des donnees des commanditaires par des tiers impliques dans la fourniture du service (fournisseurs, sous-traitants, etc.) ; les risques lies aux evenements naturels et sinistres physiques ; les risques lies a la separation des taches (voir 6.2.a) ; les risques lies aux environnements de developpement (voir 14.4.b). d) Le prestataire doit lister, dans un document specifique, les risques residuels lies a l'existence de lois extra-europeennes ayant pour objectif la collecte de donnees ou metadonnees des commanditaires sans leur consentement prealable. e) Le prestataire doit mettre a la disposition du commanditaire, sur demande de celui-ci, les elements d'appreciation des risques lies a la soumission des donnees du commanditaire au droit d'un etat non-membre de l'Union Europeenne. f) Lorsqu'il existe des exigences legales, reglementaires ou sectorielles specifiques liees aux types d'informations confiees par le commanditaire au prestataire, ce dernier doit les prendre en compte dans son appreciation des risques en s'assurant de respecter l'ensemble des exigences du present referentiel d'une part et de ne pas abaisser le niveau de securite etabli par le respect des exigences du present referentiel d'autre part. g) La direction du prestataire doit accepter formellement les risques residuels identifies dans l'appreciation des risques. h) Le prestataire doit reviser annuellement l'appreciation des risques et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "6.1", + "Description": "Le prestataire doit definir et attribuer toutes les responsabilites en matiere de securite de l'information.", + "Name": "Fonctions et responsabilites liees a la securite de l'information", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une organisation interne de la securite pour assurer la definition, la mise en place et le suivi du fonctionnement operationnel de la securite de l'information au sein de son organisation. b) Le prestataire doit designer un responsable de la securite des systemes d'information et un responsable de la securite physique. c) Le prestataire doit definir et attribuer les responsabilites en matiere de securite de l'information pour le personnel implique dans la fourniture du service. d) Le prestataire doit s'assurer apres tout changement majeur pouvant avoir un impact sur le service que l'attribution des responsabilites en matiere de securite de l'information est toujours pertinente. e) Le prestataire doit definir et attribuer les responsabilites en matiere de protection de donnees a caractere personnel, en coherence avec son role dans les traitements de donnees a caractere personnel (responsable de traitement, sous-traitant ou co-responsable). f) Le prestataire doit, lorsqu'il traite un grand nombre de donnees parmi lesquelles figurent des categories particulieres de donnees a caractere personnel telles que definies dans [RGPD], designer un delegue a la protection des donnees. g) Il est recommande que le prestataire, quel que soit le volume de donnees a caractere personnel qu'il traite, designe un delegue a la protection des donnees. h) Le prestataire doit realiser ou contribuer a la realisation d'une analyse d'impact relative a la protection des donnees a caractere personnel lorsque le traitement est susceptible d'engendrer un risque eleve pour les droits et libertes des personnes concernees (traitement de categories particulieres de donnees a caractere personnel telles que definies dans [RGPD], traitement de donnees a grande echelle, etc.). Cette analyse doit comporter une evaluation juridique du respect des principes et droits fondamentaux, ainsi qu'une etude plus technique des mesures techniques mises en oeuvre pour proteger les personnes des risques pour leur vie privee." + } + ], + "Checks": [] + }, + { + "Id": "6.2", + "Description": "Le prestataire doit separer les taches et les domaines de responsabilite incompatibles afin de reduire les possibilites de modification non autorisee ou de mauvais usage des actifs.", + "Name": "Separation des taches", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les risques associes a des cumuls de responsabilites ou de taches, les prendre en compte dans l'appreciation des risques et mettre en oeuvre des mesures de reduction de ces risques." + } + ], + "Checks": [] + }, + { + "Id": "6.3", + "Description": "Le prestataire doit etablir et maintenir des relations appropriees avec les autorites competentes.", + "Name": "Relations avec les autorites", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire mette en place des relations appropriees avec les autorites competentes en matiere de securite de l'information et de donnees a caractere personnel et, le cas echeant, avec les autorites sectorielles selon la nature des informations confiees par le commanditaire au prestataire." + } + ], + "Checks": [] + }, + { + "Id": "6.4", + "Description": "Le prestataire doit etablir et maintenir des relations appropriees avec des groupes de travail specialises, des associations professionnelles ou des forums traitant de la securite.", + "Name": "Relations avec les groupes de travail specialises", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire entretienne des contacts appropries avec des groupes de specialistes ou des sources reconnues, notamment pour prendre en compte de nouvelles menaces et les mesures de securite appropriees pour les contrer." + } + ], + "Checks": [] + }, + { + "Id": "6.5", + "Description": "Le prestataire doit integrer la securite de l'information dans la gestion de projet, quel que soit le type de projet.", + "Name": "La securite de l'information dans la gestion de projet", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter une estimation des risques prealablement a tout projet pouvant avoir un impact sur le service, et ce quelle que soit la nature du projet. b) Dans la mesure ou un projet affecte ou est susceptible d'affecter le niveau de securite du service, le prestataire doit avertir le commanditaire et l'informer par ecrit des impacts potentiels, des mesures mises en place pour reduire ces impacts ainsi que des risques residuels le concernant." + } + ], + "Checks": [] + }, + { + "Id": "7.1", + "Description": "Le prestataire doit s'assurer que les candidats a l'embauche font l'objet de verifications proportionnees aux exigences metier, a la classification des informations accessibles et aux risques identifies.", + "Name": "Selection des candidats", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de verification des informations concernant son personnel conforme aux lois et reglements en vigueur. Ces verifications s'appliquent a toute personne impliquee dans la fourniture du service et doivent etre proportionnelles a la sensibilite ou a la specificite des informations du commanditaire confiees au prestataire ainsi qu'aux risques identifies. b) Pour les personnels disposant de privileges d'administration eleves sur les composants logiciels et materiels de l'infrastructure, le prestataire doit renforcer les verifications destinees a verifier que les antecedents de ceux-ci ne sont pas incompatibles avec l'exercice de leurs fonctions. Il est entendu par des privileges d'administration eleves, des actions permettant l'elevation de privileges ou la possibilite de realiser des actions sans traces techniques ou de desactiver, alterer les traces techniques." + } + ], + "Checks": [] + }, + { + "Id": "7.2", + "Description": "Les accords contractuels avec les salaries et les sous-traitants doivent preciser leurs responsabilites et celles du prestataire en matiere de securite de l'information.", + "Name": "Conditions d'embauche", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit disposer d'une charte d'ethique integree au reglement interieur, prevoyant notamment que : les prestations sont realisees avec loyaute, discretion et impartialite et dans des conditions de confidentialite des informations traitees ; les personnels ne recourent qu'aux methodes, outils et techniques valides par le prestataire ; les personnels s'engagent a ne pas divulguer d'informations a un tiers, meme anonymisees et decontextualisees, obtenues ou generees dans le cadre de la prestation sauf autorisation formelle et ecrite du commanditaire ; les personnels s'engagent a signaler au prestataire tout contenu manifestement illicite decouvert pendant la prestation ; les personnels s'engagent a respecter la legislation et la reglementation nationale en vigueur et les bonnes pratiques liees a leurs activites. b) Le prestataire doit faire signer la charte d'ethique a l'ensemble des personnes impliquees dans la fourniture du service. c) Le prestataire doit introduire, dans le contrat de travail des personnels disposant de privileges d'administration eleves sur les composants et materiels de l'infrastructure du service, un engagement de responsabilite avec un renvoi aux clauses du code du travail sur la protection du secret des affaires et de la propriete intellectuelle. Il est entendu par des privileges d'administration eleves, des actions permettant l'elevation de privileges ou la possibilite de realiser des actions sans traces techniques ou de desactiver, alterer les traces techniques. d) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible le reglement interieur et la charte d'ethique." + } + ], + "Checks": [] + }, + { + "Id": "7.3", + "Description": "Les salaries du prestataire et, le cas echeant, les sous-traitants doivent suivre un programme de sensibilisation et de formation adapte et regulier concernant la securite de l'information.", + "Name": "Sensibilisation, apprentissage et formations a la securite de l'information", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit sensibiliser a la securite de l'information et aux risques lies a la protection des donnees l'ensemble des personnes impliquees dans la fourniture du service. Il doit leur communiquer les mises a jour des politiques et procedures pertinentes dans le cadre de leurs missions. b) Le prestataire doit documenter et mettre en oeuvre un plan de formation concernant la securite de l'information adapte au service et aux missions des personnels. c) Le responsable de la securite des systemes d'information du prestataire doit valider formellement le plan de formation concernant la securite de l'information." + } + ], + "Checks": [] + }, + { + "Id": "7.4", + "Description": "Le prestataire doit mettre en place un processus disciplinaire formel et communique pour prendre des mesures a l'encontre des salaries ayant enfreint les regles de securite de l'information.", + "Name": "Processus disciplinaire", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus disciplinaire applicable a l'ensemble des personnes impliquees dans la fourniture du service ayant enfreint la politique de securite. b) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible les sanctions encourues en cas d'infraction a la politique de securite." + } + ], + "Checks": [] + }, + { + "Id": "7.5", + "Description": "Les responsabilites et les obligations en matiere de securite de l'information qui restent valables apres un changement ou une rupture du contrat de travail doivent etre definies, communiquees au salarie ou au sous-traitant et appliquees.", + "Name": "Rupture, terme ou modification du contrat de travail", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit definir et attribuer les roles et les responsabilites relatives a la rupture, au terme ou a la modification de tout contrat avec une personne impliquee dans la fourniture du service." + } + ], + "Checks": [] + }, + { + "Id": "8.1", + "Description": "Le prestataire doit identifier les actifs associes a l'information et aux moyens de traitement de l'information et doit etablir et tenir a jour un inventaire de ces actifs.", + "Name": "Inventaire et propriete des actifs", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "config", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit tenir a jour l'inventaire de l'ensemble des equipements mettant en oeuvre le service. Cet inventaire doit preciser pour chaque equipement : les informations d'identification de l'equipement (noms, adresses IP, adresses MAC, etc.) ; la fonction de l'equipement ; le modele de l'equipement ; la localisation de l'equipement ; le proprietaire de l'equipement ; le besoin de securite des informations (au sens du chapitre 8.3). b) Le prestataire doit tenir a jour l'inventaire de l'ensemble des logiciels mettant en oeuvre le service. Cet inventaire doit identifier pour chaque logiciel, sa version et les equipements sur lesquels le logiciel est installe. c) Le prestataire doit s'assurer de la validite des licences des logiciels tout au long de la prestation." + } + ], + "Checks": [ + "policy_ensure_asc_enforcement_enabled" + ] + }, + { + "Id": "8.2", + "Description": "Les salaries et les utilisateurs de tiers doivent restituer tous les actifs du prestataire en leur possession au terme de la periode d'emploi, du contrat ou de l'accord.", + "Name": "Restitution des actifs", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de restitution des actifs permettant de s'assurer que chaque personne impliquee dans la fourniture du service restitue l'ensemble des actifs en sa possession a la fin de sa periode d'emploi ou de son contrat." + } + ], + "Checks": [] + }, + { + "Id": "8.3", + "Description": "Les besoins de protection de la confidentialite, de l'integrite et de la disponibilite de l'information doivent etre identifies.", + "Name": "Identification des besoins de securite de l'information", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les differents besoins de securite des informations relatives au service. b) Lorsque le commanditaire confie au prestataire des donnees soumises a des contraintes legales, reglementaires ou sectorielles specifiques, le prestataire doit identifier les besoins de securite specifiques associes a ces contraintes." + } + ], + "Checks": [] + }, + { + "Id": "8.4", + "Description": "Un ensemble de procedures appropriees pour le marquage et la manipulation de l'information doit etre elabore et mis en oeuvre conformement au plan de classification adopte par le prestataire.", + "Name": "Marquage et manipulation de l'information", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire documente et mette en oeuvre une procedure pour le marquage et la manipulation de toutes les informations participant a la delivrance du service, conformement a son besoin de securite defini au chapitre 8.3." + } + ], + "Checks": [] + }, + { + "Id": "8.5", + "Description": "Des procedures de gestion des supports amovibles doivent etre mises en oeuvre conformement au plan de classification adopte par le prestataire.", + "Name": "Gestion des supports amovibles", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure pour la gestion des supports amovibles, conformement au besoin de securite defini au chapitre 8.3. Lorsque des supports amovibles sont utilises sur l'infrastructure technique ou pour des taches d'administration, ces supports doivent etre dedies a un usage." + } + ], + "Checks": [] + }, + { + "Id": "9.1", + "Description": "Une politique de controle d'acces doit etre etablie, documentee et revue en se basant sur les exigences metier et les exigences de securite de l'information. Les regles de controle d'acces et les droits pour chaque utilisateur ou groupe d'utilisateurs doivent etre clairement definis.", + "Name": "Politiques et controle d'acces", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de controle d'acces sur la base du resultat de son appreciation des risques et du partage des responsabilites. b) Le prestataire doit reviser annuellement la politique de controle d'acces et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [ + "iam_subscription_roles_owner_custom_not_created", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "iam_role_user_access_admin_restricted", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants" + ] + }, + { + "Id": "9.2", + "Description": "Un processus formel d'enregistrement et de desinscription des utilisateurs doit etre mis en oeuvre pour permettre l'attribution des droits d'acces.", + "Name": "Enregistrement et desinscription des utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure d'enregistrement et de desinscription des utilisateurs s'appuyant sur une interface de gestion des comptes et des droits d'acces. Cette procedure doit indiquer quelles donnees doivent etre supprimees au depart d'un utilisateur. b) Le prestataire doit attribuer des comptes nominatifs lors de l'enregistrement des utilisateurs places sous sa responsabilite. c) Le prestataire doit mettre en oeuvre des moyens permettant de s'assurer que la desinscription d'un utilisateur entraine la suppression de tous ses acces aux ressources du systeme d'information du service, ainsi que la suppression de ses donnees conformement a la procedure d'enregistrement et de desinscription (voir exigence 9.2 a))." + } + ], + "Checks": [] + }, + { + "Id": "9.3", + "Description": "Un processus formel de gestion des droits d'acces doit etre mis en oeuvre pour controler l'attribution des droits d'acces a tous les types d'utilisateurs et a tous les systemes et services.", + "Name": "Gestion des droits d'acces", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'assurer l'attribution, la modification et le retrait de droits d'acces aux ressources du systeme d'information du service. b) Le prestataire doit mettre a la disposition de ses commanditaires les outils et les moyens qui permettent une differenciation des roles des utilisateurs du service, par exemple suivant leur role fonctionnel. c) Le prestataire doit tenir a jour l'inventaire des utilisateurs sous sa responsabilite disposant de droits d'administration sur les ressources du systeme d'information du service. d) Le prestataire doit etre en mesure de fournir, pour une ressource donnee mettant en oeuvre le service, la liste de tous les utilisateurs y ayant acces, qu'ils soient sous la responsabilite du prestataire ou du commanditaire ainsi que les droits d'acces qui leurs ont ete attribues. e) Le prestataire doit etre en mesure de fournir, pour un utilisateur donne, qu'ils soient sous la responsabilite du prestataire ou du commanditaire, la liste de tous ses droits d'acces sur les differents elements du systeme d'information du service. f) Le prestataire doit definir une liste de droits d'acces incompatibles entre eux. Il doit s'assurer, lors de l'attribution de droits d'acces a un utilisateur qu'il ne possede pas de droits d'acces incompatibles entre eux au titre de la liste precedemment etablie. g) Le prestataire doit inclure dans la procedure de gestion des droits d'acces les actions de revocation ou de suspension des droits de tout utilisateur." + } + ], + "Checks": [ + "iam_subscription_roles_owner_custom_not_created", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "iam_role_user_access_admin_restricted", + "entra_global_admin_in_less_than_five_users" + ] + }, + { + "Id": "9.4", + "Description": "Les proprietaires d'actifs doivent verifier les droits d'acces des utilisateurs a intervalles reguliers.", + "Name": "Revue des droits d'acces utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit reviser annuellement les droits d'acces des utilisateurs sur son perimetre de responsabilite. b) Le prestataire doit mettre a disposition du commanditaire un outil facilitant la revue des droits d'acces des utilisateurs places sous la responsabilite de ce dernier. c) Le prestataire doit reviser trimestriellement la liste des utilisateurs sur son perimetre de responsabilite pouvant utiliser les comptes techniques mentionnes dans l'exigence 9.2 b)." + } + ], + "Checks": [] + }, + { + "Id": "9.5", + "Description": "L'attribution et l'utilisation des informations secretes d'authentification doivent etre gerees dans le cadre d'un processus de gestion formel incluant une politique de mot de passe robuste et l'utilisation de l'authentification multi-facteur.", + "Name": "Gestion des authentifications des utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit formaliser et mettre en oeuvre des procedures de gestion de l'authentification des utilisateurs. En accord avec les exigences du chapitre 10, celles-ci doivent notamment porter sur : la gestion des moyens d'authentification (emission et reinitialisation de mot de passe, mise a jour des CRL et import des certificats racines en cas d'utilisation de certificats, etc.) ; la mise en place des moyens permettant une authentification a multiples facteurs afin de repondre aux differents cas d'usage du referentiel ; les systemes qui generent des mots de passe ou verifient leur robustesse, lorsqu'une authentification par mot de passe est utilisee. Ils doivent suivre les recommandations de [G_AUTH]. b) Tout mecanisme d'authentification doit prevoir le blocage d'un compte apres un nombre limite de tentatives infructueuses. c) Dans le cadre d'un service SaaS, le prestataire doit proposer a ses commanditaires des moyens d'authentification a multiples facteurs pour l'acces des utilisateurs finaux. d) Lorsque des comptes techniques, non nominatifs, sont necessaires, le prestataire doit mettre en place des mesures obligeant les utilisateurs a s'authentifier avec leur compte nominatif avant de pouvoir acceder a ces comptes techniques." + } + ], + "Checks": [ + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled" + ] + }, + { + "Id": "9.6", + "Description": "L'acces aux interfaces d'administration du service cloud doit etre restreint et protege par des mecanismes d'authentification forte, incluant l'utilisation de dispositifs MFA materiels pour les comptes a privileges.", + "Name": "Acces aux interfaces d'administration", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Les comptes d'administration sous la responsabilite du prestataire doivent etre geres a l'aide d'outils et d'annuaires distincts de ceux utilises pour la gestion des comptes utilisateurs places sous la responsabilite du commanditaire. b) Les interfaces d'administration mises a disposition des commanditaires doivent etre distinctes des interfaces d'administration utilisees par le prestataire. c) Les interfaces d'administration mises a disposition des commanditaires ne doivent permettre aucune connexion avec des comptes d'administrateurs sous la responsabilite du prestataire. d) Les interfaces d'administration utilisees par le prestataire ne doivent pas etre accessibles a partir d'un reseau public et ainsi ne doivent permettre aucune connexion des utilisateurs sous la responsabilite du commanditaire. e) Si des interfaces d'administration sont mises a disposition des commanditaires avec un acces via un reseau public, les flux d'administration doivent etre authentifies et chiffres avec des moyens en accord avec les exigences du chapitre 10.2. f) Le prestataire doit mettre en place un systeme d'authentification multifacteur fort pour l'acces : aux interfaces d'administration utilisees par le prestataire ; aux interfaces d'administration dediees aux commanditaires. g) Dans le cadre d'un service SaaS, les interfaces d'administration mises a disposition des commanditaires doivent etre differenciees des interfaces permettant l'acces des utilisateurs finaux. h) Des lors qu'une interface d'administration est accessible depuis un reseau public, le processus d'authentification doit avoir lieu avant toute interaction entre l'utilisateur et l'interface en question. i) Lorsque le prestataire utilise un service de type IaaS comme socle d'un autre type de service (CaaS, PaaS ou SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service IaaS. j) Lorsque le prestataire utilise un service de type CaaS comme socle d'un autre type de service (PaaS ou SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service CaaS. k) Lorsque le prestataire utilise un service de type PaaS comme socle d'un autre type de service (typiquement SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service PaaS." + } + ], + "Checks": [ + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_global_admin_in_less_than_five_users", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted" + ] + }, + { + "Id": "9.7", + "Description": "L'acces a l'information et aux fonctions d'application des systemes doit etre restreint conformement a la politique de controle d'acces. Les ressources doivent etre protegees contre tout acces public non autorise.", + "Name": "Restriction des acces a l'information", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "ec2", + "Type": "Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre des mesures de cloisonnement appropriees entre ses commanditaires. b) Le prestataire doit mettre en oeuvre des mesures de cloisonnement appropriees entre le systeme d'information du service et ses autres systemes d'information (bureautique, informatique de gestion, gestion technique du batiment, controle d'acces physique, etc.). c) Le prestataire doit concevoir, developper, configurer et deployer le systeme d'information du service en assurant au moins un cloisonnement entre d'une part l'infrastructure technique et d'autre part les equipements necessaires a l'administration des services et des ressources qu'elle heberge. d) Dans le cadre du support technique, si les actions necessaires au diagnostic et a la resolution d'un probleme rencontre par un commanditaire necessitent un acces aux donnees du commanditaire, alors le prestataire doit : n'autoriser l'acces aux donnees du commanditaire qu'apres consentement explicite du commanditaire ; verifier que la personne a qui l'acces doit etre autorise a satisfait aux verifications de l'exigence 7.1.b ; dans le cas d'une intervention realisee a distance par une personne localisee hors de l'Union Europeenne, mettre en oeuvre une passerelle securisee (poste de rebond) par laquelle la personne devra se connecter et permettant une supervision (autorisation ou interdiction des actions, demandes d'explications, etc.) en temps reel, par une personne ayant elle-meme satisfait aux verifications de l'exigence 7.1.b ; considerer les actions menees, une fois l'acces autorise, comme des actions d'administration et les journaliser comme telles ; supprimer l'autorisation d'acces aux donnees du commanditaire au terme de ces actions." + } + ], + "Checks": [ + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "sqlserver_unrestricted_inbound_access", + "postgresql_flexible_server_allow_access_services_disabled", + "aisearch_service_not_publicly_accessible", + "app_function_not_publicly_accessible", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_use_private_endpoints" + ] + }, + { + "Id": "10.1", + "Description": "Les donnees stockees dans le cadre du service cloud doivent etre chiffrees au repos en utilisant des algorithmes et des longueurs de cle conformes a l'etat de l'art.", + "Name": "Chiffrement des donnees stockees", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "ec2", + "Type": "Automated", + "Comment": "a) Le prestataire doit definir et mettre en oeuvre un mecanisme de chiffrement empechant la recuperation des donnees des commanditaires en cas de reallocation d'une ressource ou de recuperation du support physique. Dans le cas d'un service IaaS ou CaaS, cet objectif pourra par exemple etre atteint par un chiffrement du disque ou du systeme de fichier, lorsque le protocole d'acces en mode fichiers garantit que seuls des blocs vides peuvent etre alloues, ou par un chiffrement par volume dans le cas d'un acces en mode bloc, avec au moins une cle par commanditaire. Dans le cas d'un service PaaS ou SaaS, cet objectif pourra etre atteint en utilisant un chiffrement applicatif dans le perimetre du prestataire, avec au moins une cle par commanditaire. b) Le prestataire doit utiliser une methode de chiffrement des donnees respectant les regles de [CRYPTO_B1]. c) Il est recommande d'utiliser une methode de chiffrement des donnees respectant les recommandations de [CRYPTO_B1]. d) Le prestataire doit mettre en place un chiffrement des donnees sur les supports amovibles et les supports de sauvegarde amenes a quitter le perimetre de securite physique du systeme d'information du service (au sens du chapitre 10), en fonction du besoin de securite des donnees (voir chapitre 8.3)." + } + ], + "Checks": [ + "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", + "sqlserver_tde_encryption_enabled", + "sqlserver_tde_encrypted_with_cmk", + "databricks_workspace_cmk_encryption_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted" + ] + }, + { + "Id": "10.2", + "Description": "Les flux de donnees entre les composants du service cloud et entre le service et les commanditaires doivent etre chiffres en transit en utilisant des protocoles et des algorithmes conformes a l'etat de l'art.", + "Name": "Chiffrement des flux", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "elb", + "Type": "Automated", + "Comment": "a) Lorsque le prestataire met en oeuvre un mecanisme de chiffrement des flux reseau, celui-ci doit respecter les regles de [CRYPTO_B1]. b) Lorsque le prestataire met en oeuvre un mecanisme de chiffrement des flux reseau, il est recommande que celui-ci respecte les recommandations de [CRYPTO_B1]. c) Si le protocole TLS est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_TLS]. d) Si le protocole IPsec est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_IPSEC]. e) Si le protocole SSH est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_SSH]." + } + ], + "Checks": [ + "storage_secure_transfer_required_is_enabled", + "storage_ensure_minimum_tls_version_12", + "storage_smb_channel_encryption_with_secure_algorithm", + "storage_smb_protocol_version_is_latest", + "app_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "app_ftp_deployment_disabled", + "app_function_ftps_deployment_disabled", + "sqlserver_recommended_minimal_tls_version", + "postgresql_flexible_server_enforce_ssl_enabled", + "mysql_flexible_server_ssl_connection_enabled", + "mysql_flexible_server_minimum_tls_version_12" + ] + }, + { + "Id": "10.3", + "Description": "Les mots de passe doivent etre stockes sous forme hachee en utilisant des algorithmes robustes conformes a l'etat de l'art et les politiques de mot de passe doivent imposer des exigences de complexite adequates.", + "Name": "Hachage des mots de passe", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "iam", + "Type": "Partially Automated", + "Comment": "a) Le prestataire ne doit stocker que l'empreinte des mots de passe des utilisateurs et des comptes techniques. b) Le prestataire doit mettre en oeuvre une fonction de hachage respectant les regles de [CRYPTO_B1]. c) Il est recommande que le prestataire mette en oeuvre une fonction de hachage respectant les recommandations de [CRYPTO_B1]. d) Le prestataire doit generer les empreintes des mots de passe avec une fonction de hachage associee a l'utilisation d'un sel cryptographique respectant les regles de [CRYPTO_B1]." + } + ], + "Checks": [] + }, + { + "Id": "10.4", + "Description": "Des mecanismes de non-repudiation doivent etre mis en oeuvre pour assurer la tracabilite des actions effectuees sur le service cloud, incluant la validation de l'integrite des journaux.", + "Name": "Non repudiation", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "cloudtrail", + "Type": "Partially Automated", + "Comment": "a) Lorsque le prestataire met en oeuvre un mecanisme de signature electronique, celui-ci doit respecter les regles de [CRYPTO_B1]. b) Lorsque le prestataire met en oeuvre un mecanisme de signature electronique, il est recommande que celui-ci respecte les recommandations de [CRYPTO_B1]." + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists" + ] + }, + { + "Id": "10.5", + "Description": "Les secrets cryptographiques (cles, certificats, mots de passe) doivent etre geres de maniere securisee tout au long de leur cycle de vie, incluant la generation, le stockage, la distribution, la rotation et la destruction.", + "Name": "Gestion des secrets", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "kms", + "Type": "Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre des cles cryptographiques respectant les regles de [CRYPTO_B2]. b) Il est recommande que le prestataire mette en oeuvre des cles cryptographiques respectant les recommandations de [CRYPTO_B2]. c) Le prestataire doit proteger l'acces aux cles cryptographiques et autres secrets utilises pour le chiffrement des donnees par un moyen adapte : conteneur de securite (logiciel ou materiel) ou support disjoint. d) Le prestataire doit proteger l'acces aux cles cryptographiques et autres secrets utilises pour les taches d'administration par un conteneur de securite adapte, logiciel ou materiel." + } + ], + "Checks": [ + "keyvault_key_rotation_enabled", + "keyvault_recoverable", + "keyvault_rbac_enabled", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_logging_enabled", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints" + ] + }, + { + "Id": "10.6", + "Description": "Les racines de confiance (certificats racine, autorites de certification) utilisees dans le cadre du service cloud doivent etre gerees de maniere securisee. Les certificats doivent etre valides et utiliser des algorithmes de cle robustes.", + "Name": "Racines de confiance", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "acm", + "Type": "Partially Automated", + "Comment": "a) Sur l'infrastructure technique, le prestataire doit utiliser exclusivement des certificats de cle publique issus d'une autorite de certification d'un Etat membre de l'Union Europeenne (les ceremonies de generation des cles maitresses doivent avoir lieu dans un pays membre de l'Union Europeenne et en presence du prestataire)." + } + ], + "Checks": [] + }, + { + "Id": "11.1", + "Description": "Des perimetres de securite doivent etre definis et utilises pour proteger les zones contenant des informations sensibles ou critiques et les moyens de traitement de l'information.", + "Name": "Perimetres de securite physique", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des perimetres de securite, incluant le marquage des zones et les differents moyens de limitation et de controle des acces. b) Le prestataire doit distinguer des zones publiques, des zones privees et des zones sensibles. 11.1.1. Zones publiques : a) Les zones publiques sont accessibles a tous dans les limites de la propriete du prestataire. Le prestataire ne doit heberger aucune ressource devolue au service ou permettant d'acceder a des composantes de celui-ci dans les zones publiques. 11.1.2. Zones privees : a) Les zones privees peuvent heberger : les plateformes et moyens de developpement du service ; les postes d'administration, d'exploitation et de supervision ; les locaux a partir desquels le prestataire opere. 11.1.3. Zones sensibles : a) Les zones sensibles sont reservees a l'hebergement du systeme d'information de production du service hors postes d'administration, d'exploitation et de supervision." + } + ], + "Checks": [] + }, + { + "Id": "11.2", + "Description": "Les zones securisees doivent etre protegees par des controles d'acces physiques adequats pour s'assurer que seul le personnel autorise est admis.", + "Name": "Controle d'acces physique", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "11.2.1. Zones privees : a) Le prestataire doit proteger les zones privees contre les acces non autorises. Pour ce faire, il doit mettre en oeuvre un controle d'acces physique reposant au moins sur un facteur personnel : la connaissance d'un secret, la detention d'un objet ou la biometrie. b) Il est recommande que le prestataire respecte les recommandations de [G_CVAP] pour mettre en oeuvre du controle d'acces physique. c) Le prestataire doit definir et documenter des mesures d'acces physique derogatoires en cas d'urgence. d) Le prestataire doit afficher a l'entree des zones privees un avertissement relatif aux limites et conditions d'acces a ces zones. e) Le prestataire doit definir et documenter les plages horaires et conditions d'acces aux zones privees en fonction des profils des intervenants. f) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de s'assurer que les visiteurs sont systematiquement accompagnes par le prestataire lors de leurs acces et sejours en zone privee. Le prestataire doit conserver une trace de l'identite des visiteurs conformement a la legislation et reglementation en vigueur. g) En cas d'intervention (actions de diagnostic, de maintenance, ou d'administration) en zone privee par un tiers visiteur, le prestataire doit faire superviser (suivre, autoriser, interdire, questionner) les actions par un personnel ayant satisfait aux verifications de l'exigence 7.1.b. h) Le prestataire doit documenter et mettre en oeuvre des mecanismes de surveillance et de detection des acces non autorises aux zones privees. 11.2.2. Zones sensibles : a) Le prestataire doit proteger les zones sensibles contre les acces non autorises. Pour ce faire, il doit mettre en oeuvre un controle d'acces physique reposant au moins sur deux facteurs personnels : la connaissance d'un secret, la detention d'un objet ou la biometrie. b) Il est recommande que le prestataire respecte les recommandations de [G_CVAP] pour la mise en oeuvre du controle d'acces physique. c) Le prestataire doit definir et documenter des mesures d'acces physique derogatoires en cas d'urgence. d) Le prestataire doit afficher a l'entree des zones sensibles un avertissement relatif aux limites et conditions d'acces a ces zones. e) Le prestataire doit definir et documenter les plages horaires et conditions d'acces aux zones sensibles en fonction des profils des intervenants. f) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de s'assurer que les visiteurs sont systematiquement accompagnes par le prestataire lors de leurs acces et sejours en zone sensible. Le prestataire doit conserver une trace de l'identite des visiteurs conformement a la legislation et reglementation en vigueur. g) En cas d'intervention (actions de diagnostic, de maintenance, ou d'administration) en zone sensible par un tiers visiteur, le prestataire doit faire superviser (suivre, autoriser, interdire, questionner) les actions par un personnel ayant satisfait aux verifications de l'exigence 7.1.b. h) Le prestataire doit documenter et mettre en oeuvre des mecanismes de surveillance et de detection des acces non autorises aux zones sensibles. i) Le prestataire doit mettre en place une journalisation des acces physiques aux zones sensibles. Il doit effectuer une revue de ces journaux au moins mensuellement. j) Le prestataire doit mettre en oeuvre les moyens garantissant qu'aucun acces direct n'existe entre une zone publique et une zone sensible." + } + ], + "Checks": [] + }, + { + "Id": "11.3", + "Description": "Des mesures de protection contre les menaces exterieures et environnementales, telles que les catastrophes naturelles, les attaques malveillantes ou les accidents, doivent etre concues et appliquees.", + "Name": "Protection contre les menaces exterieures et environnementales", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de minimiser les risques inherents aux sinistres physiques (incendie, degat des eaux, etc.) et naturels (risques climatiques, inondations, seismes, etc.). b) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de limiter les risques de depart et de propagation de feu ainsi que les risques de degat des eaux. c) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de prevenir et limiter les consequences d'une coupure d'alimentation electrique et permettre une reprise du service conformement aux exigences de disponibilite du service definies dans la convention de service. d) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de maintenir des conditions de temperature et d'humidite adaptees aux equipements. De plus, il doit mettre en oeuvre des mesures permettant de prevenir les pannes de climatisation et d'en limiter les consequences. e) Le prestataire doit documenter et mettre en oeuvre des controles et tests reguliers des equipements de detection et de protection physique." + } + ], + "Checks": [] + }, + { + "Id": "11.4", + "Description": "Des mesures de securite physique pour le travail dans les zones privees et sensibles doivent etre concues et appliquees.", + "Name": "Travail dans les zones privees et sensibles", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit integrer les elements de securite physique dans la politique de securite et l'appreciation des risques conformement au niveau de securite requis par la categorie de la zone. b) Le prestataire doit documenter et mettre en oeuvre des procedures relatives au travail en zones privees et sensibles. Il doit communiquer ces procedures aux intervenants concernes." + } + ], + "Checks": [] + }, + { + "Id": "11.5", + "Description": "Les points d'acces tels que les zones de livraison et de chargement et les autres points par lesquels des personnes non autorisees peuvent penetrer dans les locaux doivent etre controles.", + "Name": "Zones de livraison et de chargement", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Les zones de livraison et de chargement et les autres points par lesquels des personnes non autorisees peuvent penetrer dans les locaux sans etre accompagnees sont considerees comme des zones publiques. b) Le prestataire doit isoler les points d'acces de ces zones vers les zones privees et sensibles, de facon a eviter les acces non autorises, ou a defaut, implementer des mesures compensatoires permettant d'assurer le meme niveau de securite." + } + ], + "Checks": [] + }, + { + "Id": "11.6", + "Description": "Le cablage electrique et de telecommunications transportant des donnees ou supportant des services d'information doit etre protege contre les interceptions, les interferences ou les dommages.", + "Name": "Securite du cablage", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de proteger le cablage electrique et de telecommunication des dommages physiques et des possibilites d'interception. b) Le prestataire doit etablir et tenir a jour un plan de cablage. c) Il est recommande que le prestataire mette en oeuvre des mesures permettant d'identifier les cables (par exemple code couleur, etiquette, etc.) afin d'en faciliter l'exploitation et limiter les erreurs de manipulation." + } + ], + "Checks": [] + }, + { + "Id": "11.7", + "Description": "Les materiels doivent etre entretenus correctement pour garantir leur disponibilite permanente et leur integrite.", + "Name": "Maintenance des materiels", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de s'assurer que les conditions d'installation, de maintenance et d'entretien des equipements du systeme d'information du service heberges en zones privees et sensibles sont compatibles avec les exigences de confidentialite et de disponibilite du service definies dans la convention de service. b) Le prestataire doit souscrire des contrats de maintenance permettant de disposer des mises a jour de securite des logiciels installes sur les equipements du systeme d'information du service. c) Le prestataire doit s'assurer que les supports ne peuvent etre retournes a un tiers que si les donnees du commanditaire y sont stockees chiffrees conformement au chapitre 10.1 ou ont prealablement ete detruites a l'aide d'un mecanisme d'effacement securise par reecriture de motifs aleatoires. d) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de s'assurer que les conditions d'installation, de maintenance et d'entretien des equipements techniques annexes (alimentation electrique, climatisation, incendie, etc.) sont compatibles avec les exigences de disponibilite du service definies dans la convention de service." + } + ], + "Checks": [] + }, + { + "Id": "11.8", + "Description": "Les materiels, les informations ou les logiciels ne doivent pas etre sortis des locaux du prestataire sans autorisation prealable.", + "Name": "Sortie des actifs", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de transfert hors site de donnees du commanditaire, equipements et logiciels. Cette procedure doit necessiter que la direction du prestataire donne son autorisation ecrite. Dans tous les cas, le prestataire doit mettre en oeuvre les moyens permettant de garantir que le niveau de protection en confidentialite et en integrite des actifs durant leur transport est equivalent a celui sur site." + } + ], + "Checks": [] + }, + { + "Id": "11.9", + "Description": "Tous les composants des equipements contenant des supports de stockage doivent etre verifies pour s'assurer que toute donnee sensible et tout logiciel sous licence ont ete supprimes ou ecrases de facon securisee avant leur mise au rebut ou leur reutilisation.", + "Name": "Recyclage securise du materiel", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des moyens permettant d'effacer de maniere securisee par reecriture de motifs aleatoires tout support de donnees mis a disposition d'un commanditaire. Si l'espace de stockage est chiffre dans le cadre de l'exigence 10.1.a), l'effacement peut etre realise par un effacement securise de la cle de chiffrement." + } + ], + "Checks": [] + }, + { + "Id": "11.10", + "Description": "Le materiel en attente d'utilisation doit etre protege de maniere adequate.", + "Name": "Materiel en attente d'utilisation", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de protection du materiel en attente d'utilisation." + } + ], + "Checks": [] + }, + { + "Id": "12.1", + "Description": "Les procedures d'exploitation doivent etre documentees et mises a disposition de tous les utilisateurs concernes.", + "Name": "Procedures d'exploitation documentees", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter les procedures d'exploitation, les tenir a jour et les rendre accessibles au personnel concerne." + } + ], + "Checks": [] + }, + { + "Id": "12.2", + "Description": "Les changements apportes au systeme d'information du prestataire, aux processus metier, aux moyens de traitement de l'information et aux systemes qui ont une incidence sur la securite de l'information doivent etre geres.", + "Name": "Gestion des changements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "config", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de gestion des changements apportes aux systemes et moyens de traitement de l'information. b) Le prestataire doit documenter et mettre en oeuvre une procedure permettant, en cas d'operations realisees par le prestataire et pouvant avoir un impact sur la securite ou la disponibilite du service, de communiquer au plus tot a l'ensemble de ses commanditaires les informations suivantes : la date et l'heure programmees du debut et de la fin des operations ; la nature des operations ; les impacts sur la securite ou la disponibilite du service ; le contact au sein du prestataire. c) Dans le cadre d'un service PaaS, le prestataire doit informer au plus tot le commanditaire de toute modification a venir sur des elements logiciels sous sa responsabilite des lors que la compatibilite complete ne peut etre assuree. d) Le prestataire doit informer au plus tot le commanditaire de toute modification a venir sur les elements du service des lors qu'elle est susceptible d'occasionner une perte de fonctionnalite pour le commanditaire." + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories" + ] + }, + { + "Id": "12.3", + "Description": "Les environnements de developpement, de test et d'exploitation doivent etre separes pour reduire les risques d'acces non autorise ou de changements non souhaites dans l'environnement d'exploitation.", + "Name": "Separation des environnements de developpement, de test et d'exploitation", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "organizations", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de separer physiquement les environnements lies a la production du service des autres environnements, dont les environnements de developpement." + } + ], + "Checks": [] + }, + { + "Id": "12.4", + "Description": "Des mesures de detection, de prevention et de recuperation conjuguees a une sensibilisation des utilisateurs doivent etre mises en oeuvre pour proteger le systeme d'information contre les codes malveillants.", + "Name": "Mesures contre les codes malveillants", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "guardduty", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures de detection, de prevention et de restauration pour se proteger des codes malveillants. Le perimetre d'application de cette exigence sur le systeme d'information du service doit necessairement contenir les postes utilisateurs sous la responsabilite du prestataire et les flux entrants sur ce meme systeme d'information. b) Le prestataire doit documenter et mettre en oeuvre une sensibilisation de ses employes aux risques lies aux codes malveillants et aux bonnes pratiques pour reduire l'impact d'une infection." + } + ], + "Checks": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_container_images_scan_enabled" + ] + }, + { + "Id": "12.5", + "Description": "Des copies de sauvegarde des informations, des logiciels et des images systeme doivent etre effectuees et testees regulierement conformement a une politique de sauvegarde convenue.", + "Name": "Sauvegarde des informations", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "backup", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de sauvegarde et de restauration des donnees sous sa responsabilite dans le cadre du service. Cette politique doit prevoir une sauvegarde quotidienne de l'ensemble des donnees (informations, logiciels, configurations, etc.) sous la responsabilite du prestataire dans le cadre du service. b) Le prestataire doit documenter et mettre en oeuvre des mesures de protection des sauvegardes conformement a la politique de controle d'acces (voir chapitre 9). Cette politique doit prevoir une revue mensuelle des traces d'acces aux sauvegardes. c) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester regulierement la restauration des sauvegardes. d) Le prestataire doit localiser les sauvegardes a une distance suffisante des equipements principaux en coherence avec les resultats de l'appreciation de risques et permettant de faire face a des sinistres majeurs. Les sauvegardes sont assujetties aux memes exigences de localisation que les donnees operationnelles. Le ou les sites de sauvegarde sont assujettis aux memes exigences de securite que le site principal, en particulier celles listees aux chapitres 8 et 11. Les communications entre site principal et site de sauvegarde doivent etre protegees par chiffrement, conformement aux exigences du chapitre 10." + } + ], + "Checks": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period", + "storage_ensure_soft_delete_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_blob_versioning_is_enabled", + "storage_geo_redundant_enabled", + "keyvault_recoverable" + ] + }, + { + "Id": "12.6", + "Description": "Des journaux d'evenements enregistrant les activites des utilisateurs, les exceptions, les defaillances et les evenements de securite de l'information doivent etre crees, tenus a jour et regulierement revus.", + "Name": "Journalisation des evenements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "cloudtrail", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de journalisation incluant au minimum les elements suivants : la liste des sources de collecte ; la liste des evenements a journaliser par source ; l'objet de la journalisation par evenement ; la frequence de la collecte et base de temps utilisee ; la duree de retention locale et centralisee ; les mesures de protection des journaux (dont chiffrement et duplication) ; la localisation des journaux. b) Le prestataire doit generer et collecter les evenements suivants : les activites des utilisateurs liees a la securite de l'information ; la modification des droits d'acces dans le perimetre de sa responsabilite ; les evenements issus des mecanismes de lutte contre les codes malveillants (voir chapitre 12.4) ; les exceptions ; les defaillances ; tout autre evenement lie a la securite de l'information. c) Le prestataire doit conserver les evenements issus de la journalisation pendant une duree minimale de six mois sous reserve du respect des exigences legales et reglementaires. d) Le prestataire doit fournir, sur demande d'un commanditaire, l'ensemble des evenements le concernant. e) Il est recommande que le systeme de journalisation mis en place par le prestataire respecte les recommandations de [NT_JOURNAL]." + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_watcher_enabled", + "app_http_logs_enabled", + "keyvault_logging_enabled", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3", + "mysql_flexible_server_audit_log_enabled", + "mysql_flexible_server_audit_log_connection_activated" + ] + }, + { + "Id": "12.7", + "Description": "Les moyens de journalisation et les informations journalisees doivent etre proteges contre les risques de falsification et les acces non autorises.", + "Name": "Protection de l'information journalisee", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "cloudtrail", + "Type": "Automated", + "Comment": "a) Le prestataire doit proteger les equipements de journalisation et les evenements journalises contre les atteintes a leur disponibilite, integrite ou confidentialite, conformement au chapitre 3.2 de [NT_JOURNAL]. b) Le prestataire doit gerer le dimensionnement de l'espace de stockage de l'ensemble des equipements hebergeant une ou plusieurs sources de collecte afin de permettre la conservation locale des evenements journalises prevue par la politique de journalisation des evenements. Cette gestion du dimensionnement doit prendre en compte les evolutions du systeme d'information. c) Le prestataire doit transferer les evenements journalises en assurant leur protection en confidentialite et en integrite, sur un ou plusieurs serveurs centraux dedies et doit les stocker sur une machine physique distincte de celle qui les a generes. d) Le prestataire doit mettre en place une sauvegarde des evenements collectes suivant une politique adaptee. e) Le prestataire doit executer les processus de journalisation et de collecte des evenements avec des comptes disposant de privileges necessaires et suffisants et doit limiter l'acces aux evenements journalises conformement a la politique de controle d'acces (voir chapitre 9.1)." + } + ], + "Checks": [ + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private" + ] + }, + { + "Id": "12.8", + "Description": "Les horloges de tous les systemes de traitement de l'information pertinents d'un organisme ou d'un domaine de securite doivent etre synchronisees sur une source de reference temporelle unique.", + "Name": "Synchronisation des horloges", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une synchronisation des horloges de l'ensemble des equipements sur une ou plusieurs sources de temps internes coherentes entre elles. Ces sources pourront elles-memes etre synchronisees sur plusieurs sources fiables externes, sauf pour les reseaux isoles. b) Le prestataire doit mettre en place l'horodatage de chaque evenement journalise." + } + ], + "Checks": [] + }, + { + "Id": "12.9", + "Description": "Les evenements de securite doivent etre analyses et correles afin de detecter les incidents de securite. Des systemes de detection et de correlation doivent etre mis en oeuvre.", + "Name": "Analyse et correlation des evenements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "securityhub", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une infrastructure permettant l'analyse et la correlation des evenements enregistres par le systeme de journalisation afin de detecter les evenements susceptibles d'affecter la securite du systeme d'information du service, en temps reel ou a posteriori pour des evenements remontant jusqu'a six mois. b) Il est recommande de s'appuyer sur le referentiel d'exigences des prestataires de detection d'incidents de securite [PDIS] pour la mise en place et l'exploitation de l'infrastructure d'analyse et de correlation des evenements. c) Le prestataire doit acquitter les alarmes remontees par l'infrastructure d'analyse et de correlation des evenements au moins quotidiennement." + } + ], + "Checks": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_additional_email_configured_with_a_security_contact", + "defender_attack_path_notifications_properly_configured", + "monitor_alert_create_policy_assignment", + "monitor_alert_delete_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_delete_nsg", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_service_health_exists" + ] + }, + { + "Id": "12.10", + "Description": "Des regles regissant l'installation de logiciels par les utilisateurs doivent etre etablies et mises en oeuvre. Les systemes doivent etre geres de maniere centralisee et les correctifs appliques regulierement.", + "Name": "Installation de logiciels sur des systemes en exploitation", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "ssm", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de controler l'installation de logiciels sur les equipements du systeme d'information du service. b) Le prestataire doit documenter et mettre en oeuvre une procedure de gestion de la configuration des environnements logiciels mis a la disposition du commanditaire, notamment pour leur maintien en condition de securite. c) Le prestataire doit fournir une capacite d'inspection et de suppression, si necessaire, des entrants (controle de l'authenticite et de l'innocuite des mises a jour, controle de l'innocuite des outils fournis, etc.) relatifs au perimetre de l'infrastructure technique : cette capacite d'inspection et de suppression doit generer des journaux d'activite et doit pouvoir faire l'objet d'un audit de code ; les entrants doivent etre traites sur des dispositifs specifiques operes et maintenus par le prestataire et heberges dans une zone cloisonnee du reste de l'infrastructure (du type zone demilitarisee telle que definie dans [G_INT])." + } + ], + "Checks": [ + "defender_ensure_system_updates_are_applied", + "defender_assessments_vm_endpoint_protection_installed" + ] + }, + { + "Id": "12.11", + "Description": "Les informations sur les vulnerabilites techniques des systemes d'information utilises doivent etre obtenues en temps voulu, l'exposition du prestataire a ces vulnerabilites doit etre evaluee et les mesures appropriees doivent etre prises pour traiter le risque associe.", + "Name": "Gestion des vulnerabilites techniques", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "inspector", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus de veille permettant de gerer les vulnerabilites techniques des logiciels et des systemes utilises dans le systeme d'information du service. b) Le prestataire doit evaluer son exposition a ces vulnerabilites en les incluant dans l'appreciation des risques et appliquer les mesures de traitement du risque adaptees." + } + ], + "Checks": [ + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_emails_notifications_admins_enabled", + "sqlserver_va_scan_reports_configured", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled" + ] + }, + { + "Id": "12.12", + "Description": "L'administration des systemes d'information du service cloud doit etre effectuee de maniere securisee via des canaux dedies et des protocoles securises.", + "Name": "Administration", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "ec2", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure obligeant les administrateurs sous sa responsabilite a utiliser des terminaux dedies pour la realisation exclusive des taches d'administration, en accord avec le chapitre 4.1 intitule 'poste et reseau d'administration' de [NT_ADMIN]. Il doit les maitriser et les maintenir a jour. b) Le prestataire doit mettre en place des mesures de durcissement de la configuration des terminaux utilises pour les taches d'administration, notamment celles du chapitre 4.2 intitule 'securisation du socle' de [NT_ADMIN]. c) Lorsque le prestataire autorise une situation de mobilite pour les administrateurs sous sa responsabilite, il doit l'encadrer par une politique documentee. La solution mise en oeuvre doit assurer que le niveau de securite de cette situation de mobilite est au moins equivalent au niveau de securite hors situation de mobilite (voir chapitres 9.6 et 9.7). Cette solution doit notamment inclure : l'utilisation d'un tunnel chiffre, non debrayable et non contournable, pour l'ensemble des flux (voir chapitre 10.2) ; le chiffrement integral du disque (voir chapitre 10.1)." + } + ], + "Checks": [ + "vm_jit_access_enabled", + "network_bastion_host_exists" + ] + }, + { + "Id": "12.13", + "Description": "Le telediagnostic et la telemaintenance des composants de l'infrastructure doivent etre encadres par des procedures de securite specifiques.", + "Name": "Telediagnostic et telemaintenance des composants de l'infrastructure", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Dans le cadre du telediagnostic ou de la telemaintenance de composants de l'infrastructure, considerant les risques d'atteinte a la confidentialite des donnees des commanditaires, le prestataire doit : verifier que la personne a qui l'acces doit etre autorise a satisfait aux verifications de l'exigence 7.1.b ; dans le cas d'une intervention realisee par une personne n'ayant pas satisfait aux verifications de l'exigence 7.1.b, mettre en oeuvre une passerelle securisee (poste de rebond) par laquelle la personne devra se connecter et permettant une supervision des actions (autorisation ou interdiction des actions, demande d'explications, etc.) en temps reel, par une personne ayant elle-meme satisfait aux verifications de l'exigence 7.1.b. La passerelle securisee devra repondre aux objectifs de securite specifies dans [G_EXT] ; considerer les actions menees, une fois l'acces autorise, comme des actions d'administration et les journaliser comme telles ; supprimer l'autorisation d'acces a l'issue de l'intervention." + } + ], + "Checks": [] + }, + { + "Id": "12.14", + "Description": "Les flux sortants de l'infrastructure du service cloud doivent etre surveilles afin de detecter et de prevenir les exfiltrations de donnees et les communications non autorisees.", + "Name": "Surveillance des flux sortants de l'infrastructure", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "vpc", + "Type": "Automated", + "Comment": "a) Le prestataire doit fournir une capacite d'inspection et de suppression des sortants de l'infrastructure technique relatifs au perimetre du service (informations de facturation, les eventuels journaux necessaires au traitement d'incidents, etc.) : les sortants doivent pouvoir etre expurges des donnees pouvant porter atteinte a la confidentialite des donnees des commanditaires ; cette capacite d'inspection et de suppression doit generer des journaux d'activite et doit pouvoir faire l'objet d'un audit de code ; les sortants sont traites sur des dispositifs specifiques operes et maintenus par le prestataire, et heberges dans une zone cloisonnee du reste de l'infrastructure (du type zone demilitarisee telle que definie dans [G_INT])." + } + ], + "Checks": [ + "network_flow_log_captured_sent", + "network_watcher_enabled" + ] + }, + { + "Id": "13.1", + "Description": "Le prestataire doit etablir et maintenir une cartographie complete et a jour de son systeme d'information, incluant les reseaux, les flux et les composants.", + "Name": "Cartographie du systeme d'information", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "config", + "Type": "Automated", + "Comment": "a) Le prestataire doit etablir et tenir a jour une cartographie du systeme d'information du service, en lien avec l'inventaire des actifs (voir chapitre 8.1), comprenant au minimum les elements suivants : la liste des ressources materielles ou virtualisees ; les noms et fonctions des applications, supportant le service ; le schema d'architecture reseau au niveau 3 du modele OSI sur lequel les points nevralgiques sont identifies : les points d'interconnexions, notamment avec les reseaux tiers et publics ; les reseaux, sous-reseaux, notamment les reseaux d'administration ; les equipements assurant des fonctions de securite (filtrage, authentification, chiffrement, etc.) ; les serveurs hebergeant des donnees ou assurant des fonctions sensibles ; la matrice des flux reseau autorises en precisant : leur description technique (services, protocoles et ports) ; la justification metier ou d'infrastructure technique ; le cas echeant, lorsque des services, protocoles ou ports reputes non surs sont utilises, les mesures compensatoires mises en place, dans la logique de defense en profondeur. b) Le prestataire doit reviser au moins annuellement la cartographie." + } + ], + "Checks": [ + "policy_ensure_asc_enforcement_enabled", + "network_watcher_enabled", + "network_flow_log_captured_sent" + ] + }, + { + "Id": "13.2", + "Description": "Les reseaux doivent etre cloisonnes et les flux entre les segments doivent etre filtres selon le principe du moindre privilege. Les groupes de securite et les listes de controle d'acces reseau doivent etre configures de maniere restrictive.", + "Name": "Cloisonnement des reseaux", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "ec2", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre, pour le systeme d'information du service, les mesures de cloisonnement (logique, physique ou par chiffrement) pour separer les flux reseau selon : la sensibilite des informations transmises ; la nature des flux (production, administration, supervision, etc.) ; le domaine d'appartenance des flux (des commanditaires - avec distinction par commanditaire ou ensemble de commanditaires, du prestataire, des tiers, etc.) ; le domaine technique (traitement, stockage, etc.). b) Le prestataire doit cloisonner, physiquement ou par chiffrement, tous les flux de donnees internes au systeme d'information du service vis-a-vis de tout autre systeme d'information. Lorsque ce cloisonnement est realise par chiffrement, il est realise en accord avec les exigences du chapitre 10.2. c) Dans le cas ou le reseau d'administration de l'infrastructure technique ne fait pas l'objet d'un cloisonnement physique, les flux d'administration doivent transiter dans un tunnel chiffre, en accord avec les exigences du chapitre 10.2. d) Le prestataire doit mettre en place et configurer un pare-feu applicatif pour proteger les interfaces d'administration destinees a ses commanditaires et exposees sur un reseau public. e) Le prestataire doit mettre en oeuvre sur l'ensemble des interfaces d'administration et de supervision de l'infrastructure technique du service un mecanisme de filtrage n'autorisant que les connexions legitimes identifiees dans la matrice des flux autorises." + } + ], + "Checks": [ + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_http_internet_access_restricted", + "network_udp_internet_access_restricted", + "aks_clusters_public_access_disabled", + "aks_network_policy_enabled", + "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_use_private_endpoints", + "storage_default_network_access_rule_is_denied", + "storage_ensure_private_endpoints_in_storage_accounts", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "keyvault_access_only_through_private_endpoints" + ] + }, + { + "Id": "13.3", + "Description": "Les reseaux doivent etre surveilles de maniere continue afin de detecter les activites anormales ou malveillantes.", + "Name": "Surveillance des reseaux", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "guardduty", + "Type": "Automated", + "Comment": "a) Le prestataire doit disposer une ou plusieurs sondes de detection d'incidents de securite sur le systeme d'information du service. Ces sondes doivent notamment permettre la supervision de chacune des interconnexions du systeme d'information du service avec des systemes d'information tiers et des reseaux publics. Ces sondes doivent etre des sources de collecte pour l'infrastructure d'analyse et de correlation des evenements (voir chapitre 12.9)." + } + ], + "Checks": [ + "defender_ensure_defender_for_server_is_on", + "network_flow_log_captured_sent", + "network_watcher_enabled" + ] + }, + { + "Id": "14.1", + "Description": "Des regles de developpement securise des logiciels et des systemes doivent etre etablies et appliquees au sein du prestataire.", + "Name": "Politique de developpement securise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des regles de developpement securise des logiciels et des systemes, et les appliquer aux developpements internes. b) Le prestataire doit documenter et mettre en oeuvre une formation adaptee en developpement securise aux employes concernes." + } + ], + "Checks": [] + }, + { + "Id": "14.2", + "Description": "Les changements apportes aux systemes dans le cycle de developpement doivent etre geres a l'aide de procedures formelles de controle des changements.", + "Name": "Procedures de controle des changements de systeme", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "config", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de controle des changements apportes au systeme d'information du service. b) Le prestataire doit documenter et mettre en oeuvre une procedure de validation des changements apportes au systeme d'information du service sur un environnement de pre-production avant leur mise en production. c) Le prestataire doit conserver un historique des versions des logiciels et des systemes (developpements internes ou externes, produits commerciaux) mis en oeuvre pour permettre de reconstituer, le cas echeant dans un environnement de test, un environnement complet tel qu'il etait mis en oeuvre a une date donnee. La duree de conservation de cet historique doit etre en accord avec celle des sauvegardes (voir chapitre 12.5)." + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories" + ] + }, + { + "Id": "14.3", + "Description": "Lorsque les plateformes d'exploitation sont modifiees, les applications critiques metier doivent etre revues et testees afin de verifier qu'il n'y a pas d'effet indesirable sur l'activite ou la securite du prestataire.", + "Name": "Revue technique des applications apres changement apporte a la plateforme d'exploitation", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester, prealablement a leur mise en production, l'ensemble des applications afin de verifier l'absence de tout effet indesirable sur l'activite ou sur la securite du service." + } + ], + "Checks": [] + }, + { + "Id": "14.4", + "Description": "Les environnements de developpement doivent etre securises et isoles des environnements de production.", + "Name": "Environnement de developpement securise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "organizations", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre un environnement securise de developpement permettant de gerer l'integralite du cycle de developpement du systeme d'information du service. b) Le prestataire doit prendre en compte les environnements de developpement dans l'appreciation des risques et en assurer la protection conformement au present referentiel." + } + ], + "Checks": [] + }, + { + "Id": "14.5", + "Description": "Le prestataire doit superviser et surveiller l'activite de developpement externalise du systeme.", + "Name": "Developpement externalise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de superviser et de controler l'activite de developpement externalise des logiciels et des systemes. Cette procedure doit s'assurer que l'activite de developpement externalise soit conforme a la politique de developpement securise du prestataire et permette d'atteindre un niveau de securite du developpement externe equivalent a celui d'un developpement interne (voir exigence 14.1 a))." + } + ], + "Checks": [] + }, + { + "Id": "14.6", + "Description": "Des tests de securite et de conformite doivent etre effectues tout au long du cycle de developpement et apres chaque changement significatif.", + "Name": "Test de la securite et conformite du systeme", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "inspector", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit soumettre les systemes d'information, nouveaux ou mis a jour, a des tests de conformite et de fonctionnalite de securite pendant le developpement. Il doit documenter et mettre en oeuvre une procedure de test qui identifie : les taches a realiser ; les donnees d'entree ; les resultats attendus en sortie." + } + ], + "Checks": [ + "sqlserver_vulnerability_assessment_enabled", + "defender_container_images_scan_enabled" + ] + }, + { + "Id": "14.7", + "Description": "Les donnees de test doivent etre soigneusement selectionnees, protegees et controlees.", + "Name": "Protection des donnees de test", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'assurer l'integrite des donnees de tests utilises en pre-production. b) Si le prestataire souhaite utiliser des donnees du commanditaire issues de la production pour realiser des tests, le prestataire doit prealablement obtenir l'accord du commanditaire et les anonymiser. Le prestataire doit assurer la confidentialite des donnees lors de leur anonymisation." + } + ], + "Checks": [] + }, + { + "Id": "15.1", + "Description": "Le prestataire doit identifier les tiers ayant acces a l'information ou aux moyens de traitement de l'information et evaluer les risques associes.", + "Name": "Identification des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit tenir a jour une liste exhaustive des tiers participant a la mise en oeuvre du service (hebergeur, developpeur, integrateur, archiveur, sous-traitant operant sur site ou a distance, fournisseurs de climatisation, etc.). Cette liste doit preciser la contribution du tiers au service et au traitement des donnees a caractere personnel. Elle doit tenir compte des cas de sous-traitance a plusieurs niveaux. b) Le prestataire doit tenir a disposition du commanditaire la liste de l'ensemble des tiers qui peuvent acceder aux donnees et l'informer de tout changement de sous-traitants au sens de l'article 28 du [RGPD] afin que le commanditaire puisse emettre des objections a cet egard." + } + ], + "Checks": [] + }, + { + "Id": "15.2", + "Description": "Tous les aspects pertinents de la securite de l'information doivent etre traites dans les accords conclus avec les tiers.", + "Name": "La securite dans les accords conclus avec les tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit exiger des tiers participant a la mise en oeuvre du service, dans leur contribution au service, un niveau de securite au moins equivalent a celui qu'il s'engage a maintenir dans sa propre politique de securite. Il doit le faire au travers d'exigences, adaptees a chaque tiers et a sa contribution au service, dans les cahiers des charges ou dans les clauses de securite des accords de partenariat. Le prestataire doit inclure ces exigences dans les contrats conclus avec les tiers. b) Le prestataire doit contractualiser, avec chacun des tiers participant a la mise en oeuvre du service, des clauses d'audit permettant a un organisme de qualification de verifier que ces tiers respectent les exigences du present referentiel. c) Le prestataire doit definir et attribuer les roles et les responsabilites relatives a la modification ou a la fin du contrat le liant a un tiers participant a la mise en oeuvre du service." + } + ], + "Checks": [] + }, + { + "Id": "15.3", + "Description": "Le prestataire doit surveiller, revoir et auditer a intervalles reguliers la prestation des services des tiers.", + "Name": "Surveillance et revue des services des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de controler regulierement les mesures mises en place par les tiers participant a la mise en oeuvre du service pour respecter les exigences du present referentiel, conformement au chapitre 18.3." + } + ], + "Checks": [] + }, + { + "Id": "15.4", + "Description": "Les changements dans les services des tiers, incluant le maintien et l'amelioration des politiques, procedures et mesures existantes de securite de l'information, doivent etre geres.", + "Name": "Gestion des changements apportes dans les services des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de suivi des changements apportes par les tiers participant a la mise en oeuvre du service susceptibles d'affecter le niveau de securite du systeme d'information du service. b) Dans la mesure ou un changement de tiers participant a la mise en oeuvre du service affecte le niveau de securite du service, le prestataire doit en informer l'ensemble des commanditaires sans delais conformement au chapitre 12.2 et mettre en oeuvre les mesures permettant de retablir le niveau de securite precedent." + } + ], + "Checks": [] + }, + { + "Id": "15.5", + "Description": "Les personnes intervenant dans le cadre du service cloud doivent etre soumises a des engagements de confidentialite.", + "Name": "Engagements de confidentialite", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de reviser au moins annuellement les exigences en matiere d'engagements de confidentialite ou de non-divulgation vis-a-vis des tiers participant a la mise en oeuvre du service." + } + ], + "Checks": [] + }, + { + "Id": "16.1", + "Description": "Des responsabilites et des procedures de gestion doivent etre etablies pour garantir une reponse rapide, efficace et ordonnee aux incidents lies a la securite de l'information.", + "Name": "Responsabilites et procedures", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'apporter des reponses rapides et efficaces aux incidents de securite. Ces procedures doivent definir les moyens et delais de communication des incidents de securite a l'ensemble des commanditaires concernes ainsi que le niveau de confidentialite exige pour cette communication. b) Le prestataire doit informer ses employes et l'ensemble des tiers participant a la mise en oeuvre du service de cette procedure. c) Le prestataire doit documenter toute violation de donnees a caractere personnel et en informer son commanditaire. La violation doit etre notifiee a la CNIL si elle presente un risque pour les droits et libertes des personnes concernees. Elle doit faire l'objet d'une information aupres des personnes concernees lorsque le risque pour leur vie privee est eleve." + } + ], + "Checks": [] + }, + { + "Id": "16.2", + "Description": "Les evenements lies a la securite de l'information doivent etre signales dans les meilleurs delais par les voies hierarchiques appropriees. Des mecanismes de detection et de notification automatises doivent etre mis en oeuvre.", + "Name": "Signalements lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "guardduty", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure exigeant de ses employes et des tiers participant a la mise en oeuvre du service qu'ils lui rendent compte de tout incident de securite, avere ou suspecte ainsi que de toute faille de securite. b) Le prestataire doit documenter et mettre en oeuvre une procedure permettant a l'ensemble des commanditaires de signaler tout incident de securite, avere ou suspecte et toute faille de securite. c) Le prestataire doit communiquer sans delai aux commanditaires les incidents de securite et les preconisations associees pour en limiter les impacts. Il doit permettre au commanditaire de choisir les niveaux de gravite des incidents pour lesquels il souhaite etre informe. d) Le prestataire doit communiquer les incidents de securite aux autorites competentes conformement aux exigences legales et reglementaires en vigueur." + } + ], + "Checks": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_additional_email_configured_with_a_security_contact" + ] + }, + { + "Id": "16.3", + "Description": "Les evenements lies a la securite de l'information doivent etre apprecies et il doit etre decide s'il est necessaire de les classer comme incidents lies a la securite de l'information.", + "Name": "Appreciation des evenements et prise de decision", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "guardduty", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit apprecier les evenements lies a la securite de l'information et decider s'il faut les qualifier en incidents de securite. Pour l'appreciation, il doit s'appuyer sur une ou plusieurs echelles (estimation, evaluation, etc.) partagees avec le commanditaire. Note : Les incidents de securite incluent les violations de donnees a caractere personnel. b) Le prestataire doit utiliser une classification permettant d'identifier clairement les incidents de securite touchant des donnees relatives aux commanditaires, conformement aux resultats de l'appreciation des risques. Cette classification doit inclure les violations de donnees a caractere personnel." + } + ], + "Checks": [ + "defender_ensure_defender_for_server_is_on" + ] + }, + { + "Id": "16.4", + "Description": "Les incidents lies a la securite de l'information doivent etre traites conformement aux procedures documentees.", + "Name": "Reponse aux incidents lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit traiter les incidents de securite jusqu'a leur resolution et doit informer les commanditaires conformement aux procedures." + } + ], + "Checks": [] + }, + { + "Id": "16.5", + "Description": "Les connaissances acquises lors de l'analyse et du traitement des incidents lies a la securite de l'information doivent etre exploitees pour reduire la probabilite ou l'impact d'incidents futurs.", + "Name": "Tirer des enseignements des incidents lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus d'amelioration continue afin de diminuer l'occurrence et l'impact de types d'incidents de securite deja traites." + } + ], + "Checks": [] + }, + { + "Id": "16.6", + "Description": "Le prestataire doit definir et appliquer des procedures pour l'identification, le recueil, l'acquisition et la preservation de preuves. Les journaux d'audit doivent etre proteges et valides.", + "Name": "Recueil de preuves", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "cloudtrail", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'enregistrer les informations relatives aux incidents de securite et pouvant servir d'elements de preuve." + } + ], + "Checks": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories" + ] + }, + { + "Id": "17.1", + "Description": "Le prestataire doit determiner ses exigences en matiere de securite de l'information et de continuite du management de la securite de l'information dans des situations defavorables, par exemple lors d'une crise ou d'un sinistre.", + "Name": "Organisation de la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre oeuvre un plan de continuite d'activite prenant en compte la securite de l'information. b) Le prestataire doit reviser annuellement le plan de continuite d'activite du service et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "17.2", + "Description": "Le prestataire doit etablir, documenter, mettre en oeuvre et maintenir des processus, des procedures et des mesures de controle pour assurer le niveau requis de continuite de la securite de l'information au cours d'une situation defavorable. Les services doivent etre deployes en multi-AZ.", + "Name": "Mise en oeuvre de la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "rds", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des procedures permettant de maintenir ou de restaurer l'exploitation du service et d'assurer la disponibilite des informations au niveau et dans les delais pour lesquels le prestataire s'est engage vis-a-vis du commanditaire dans la convention de service." + } + ], + "Checks": [ + "storage_geo_redundant_enabled", + "vm_scaleset_associated_with_load_balancer" + ] + }, + { + "Id": "17.3", + "Description": "Le prestataire doit verifier a intervalles reguliers les mesures de continuite de la securite de l'information mises en oeuvre afin de s'assurer qu'elles sont valables et efficaces dans des situations defavorables.", + "Name": "Verifier, revoir et evaluer la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester le plan de continuite d'activites afin de s'assurer qu'il est pertinent et efficace en situation de crise." + } + ], + "Checks": [] + }, + { + "Id": "17.4", + "Description": "Les moyens de traitement de l'information doivent etre mis en oeuvre avec suffisamment de redondance pour repondre aux exigences de disponibilite. Les mecanismes de protection contre la suppression accidentelle doivent etre actives.", + "Name": "Disponibilite des moyens de traitement de l'information", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "rds", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures qui lui permettent de repondre au besoin de disponibilite du service defini dans la convention de service (voir chapitre 19.1)." + } + ], + "Checks": [ + "storage_geo_redundant_enabled", + "keyvault_recoverable" + ] + }, + { + "Id": "17.5", + "Description": "La configuration de l'infrastructure technique du service cloud doit etre sauvegardee regulierement afin de permettre sa restauration en cas de sinistre.", + "Name": "Sauvegarde de la configuration de l'infrastructure technique", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "backup", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de sauvegarde hors-ligne de la configuration de l'infrastructure technique." + } + ], + "Checks": [ + "policy_ensure_asc_enforcement_enabled", + "vm_backup_enabled" + ] + }, + { + "Id": "17.6", + "Description": "Le prestataire doit mettre a disposition du commanditaire un dispositif de sauvegarde de ses donnees, permettant la restauration en cas de sinistre.", + "Name": "Mise a disposition d'un dispositif de sauvegarde des donnees du commanditaire", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "backup", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre a disposition du commanditaire un service de sauvegarde de ses donnees." + } + ], + "Checks": [ + "vm_backup_enabled", + "storage_ensure_soft_delete_is_enabled", + "storage_blob_versioning_is_enabled" + ] + }, + { + "Id": "18.1", + "Description": "Toutes les exigences legales, reglementaires et contractuelles en vigueur, ainsi que l'approche du prestataire pour satisfaire ces exigences, doivent etre explicitement definies, documentees et tenues a jour pour chaque systeme d'information et pour le prestataire.", + "Name": "Identification de la legislation et des exigences contractuelles applicables", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les exigences legales, reglementaires et contractuelles en vigueur applicables au service. En France, le prestataire doit considerer au minimum les textes suivants : les donnees a caractere personnel [LOI_IL], [RGPD] ; le secret professionnel [CP_ART_226_13], le cas echeant sans prejudice de l'application de l'article 40 alinea 2 du Code de procedure penale relatif au signalement a une autorite judiciaire ; l'abus de confiance [CP_ART_314-1] ; le secret des correspondances privees [CP_ART_226-15] ; l'atteinte a la vie privee [CP_ART_226-1] ; l'acces ou le maintien frauduleux a un systeme d'information [CP_ART_323-1]. b) Le prestataire doit, selon son role dans les traitements de donnees a caractere personnel (responsable de traitement, sous-traitant ou co-responsable) justifier et documenter les choix de mesures techniques et organisationnelles realises en vue de repondre aux exigences de protection des donnees a caractere personnel du present referentiel (voir partie 19.5). c) Le prestataire doit documenter et mettre en oeuvre les procedures permettant de respecter les exigences legales, reglementaires et contractuelles en vigueur applicables au service, ainsi que les besoins de securite specifiques (voir exigence 8.3b)). d) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible l'ensemble de ces procedures. e) Le prestataire doit documenter et mettre en oeuvre un processus de veille actif des exigences legales, reglementaires et contractuelles en vigueur applicables au service." + } + ], + "Checks": [] + }, + { + "Id": "18.2", + "Description": "L'approche du prestataire vis-a-vis de la gestion de la securite de l'information et sa mise en oeuvre (c'est-a-dire les objectifs de controle, les mesures, les politiques, les procedures et les processus relatifs a la securite de l'information) doivent etre revues de maniere independante a intervalles definis ou en cas de changement significatif.", + "Name": "Revue independante de la securite de l'information", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un programme d'audit sur trois ans definissant le perimetre et la frequence des audits en accord avec la gestion du changement, les politiques, et les resultats de l'appreciation des risques. Le prestataire doit inclure dans le programme d'audit un audit qualifie par an realise par un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie. L'ensemble du programme d'audit doit notamment couvrir : l'audit de la configuration de l'infrastructure technique du service (par echantillonnage et doit inclure tous types d'equipements et de serveurs presents dans le systeme d'information du service) ; le test d'intrusion des interfaces d'administration exposees sur un reseau public ; le test d'intrusion de l'interface utilisateur pour les services SaaS ; si le service beneficie de developpements internes, l'audit de code source portant sur les fonctionnalites de securite implementees (l'approche en continue doit etre privilegiee). b) Il est recommande que le prestataire mette en oeuvre des mecanismes automatises d'audit de la configuration adaptes a l'infrastructure technique du service." + } + ], + "Checks": [] + }, + { + "Id": "18.3", + "Description": "Les responsables doivent regulierement s'assurer de la conformite du traitement de l'information et des procedures au sein de leur domaine de responsabilite, au regard des politiques et des normes de securite.", + "Name": "Conformite avec les politiques et les normes de securite", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "securityhub", + "Type": "Partially Automated", + "Comment": "a) Le prestataire via le responsable de la securite de l'information doit s'assurer regulierement de l'execution correcte de l'ensemble des procedures de securite placees sous sa responsabilite en vue de garantir leur conformite avec les politiques et normes de securite." + } + ], + "Checks": [ + "policy_ensure_asc_enforcement_enabled", + "defender_ensure_mcas_is_enabled" + ] + }, + { + "Id": "18.4", + "Description": "Les systemes d'information doivent etre examines regulierement quant a leur conformite avec les politiques et les normes de securite de l'information du prestataire.", + "Name": "Examen de la conformite technique", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "securityhub", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique permettant de verifier la conformite technique du service aux exigences du present referentiel. Cette politique doit definir les objectifs, methodes, frequences, resultats attendus et mesures correctrices." + } + ], + "Checks": [ + "sqlserver_vulnerability_assessment_enabled", + "defender_ensure_defender_for_server_is_on" + ] + }, + { + "Id": "19.1", + "Description": "Le prestataire doit etablir une convention de service avec le commanditaire definissant les engagements de niveau de service, les responsabilites et les conditions d'utilisation du service cloud.", + "Name": "Convention de service", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit etablir une convention de service avec chacun des commanditaires du service. Toute modification de la convention de service doit etre soumise a acceptation du commanditaire. b) Le prestataire doit identifier dans la convention de service : les obligations, droits et responsabilites de chacune des parties : prestataire et tiers impliques dans la fourniture du service, commanditaires, etc. ; les elements explicitement exclus des responsabilites du prestataire dans la limite de ce que prevoient les exigences legales et reglementaires en vigueur, notamment l'article 28 du [RGPD] ; la localisation du service. La localisation du support doit etre precisee lorsqu'il est realise depuis un Etat hors l'Union Europeenne, comme le permet l'exigence 19.2.e. c) Le prestataire doit proposer une convention de service appliquant le droit d'un Etat membre de l'Union Europeenne. Le droit applicable doit etre identifie dans la convention de service. d) La convention de service doit indiquer que la collecte, la manipulation, le stockage, et plus generalement le traitement des donnees faits dans le cadre de l'avant-vente, de la mise en oeuvre, de la maintenance et l'arret du service sont realises conformement aux exigences edictees par la legislation en vigueur. e) La convention de service doit indiquer que le prestataire doit mettre a la disposition du commanditaire, sur demande de celui-ci, les elements d'appreciation des risques lies a la soumission des donnees du commanditaire au droit d'un etat non-membre de l'Union Europeenne (voir 5.3.e). f) Le prestataire doit decrire dans la convention de service les moyens techniques et organisationnels qu'il met en oeuvre pour assurer le respect du droit applicable. g) Le prestataire doit inclure dans la convention de service une clause de revision de la convention prevoyant notamment une resiliation sans penalite pour le commanditaire en cas de perte de la qualification octroyee au service. h) Le prestataire doit inclure dans la convention de service une clause de reversibilite permettant au commanditaire de recuperer l'ensemble de ses donnees (fournies directement par le commanditaire ou produites dans le cadre du service a partir des donnees ou des actions du commanditaire). i) Le prestataire doit assurer cette reversibilite via l'une des modalites techniques suivantes : la mise a disposition de fichiers suivant un ou plusieurs formats documentes et exploitables en dehors du service fourni par le prestataire ; la mise en place d'interfaces techniques permettant l'acces aux donnees suivant un schema documente et exploitable (API, format pivot, etc.). Les modalites techniques de la reversibilite figurent dans la convention de service. j) Le prestataire doit indiquer dans la convention de service le niveau de disponibilite du service. k) Le prestataire doit indiquer dans la convention de service qu'il ne peut disposer des donnees transmises et generees par le commanditaire, leur disposition etant reservee au commanditaire. l) Le prestataire doit indiquer dans la convention de service qu'il ne divulgue aucune information relative a la prestation a des tiers, sauf autorisation formelle et ecrite du commanditaire. m) Le prestataire doit indiquer dans la convention de service si les donnees du commanditaire sont automatiquement sauvegardees ou non. Dans la negative, le prestataire doit sensibiliser le commanditaire aux risques encourus et clairement indiquer les operations a mener par le commanditaire pour que ses donnees soient sauvegardees. n) Le prestataire doit indiquer dans la convention de service s'il autorise l'acces distant pour des actions d'administration ou de support au systeme d'information du service. o) Le prestataire doit preciser dans la convention de service que : le service est qualifie et inclure l'attestation de qualification ; le commanditaire peut deposer une reclamation relative au service qualifie aupres de l'ANSSI ; le commanditaire autorise l'ANSSI et l'organisme de qualification a auditer le service et son systeme d'information du service afin de verifier qu'ils respectent les exigences du present referentiel. p) Le prestataire doit preciser dans la convention de service que le commanditaire autorise, conformement au present referentiel (voir chapitre 18.2, un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie mandate par le prestataire a auditer le service et son systeme d'information dans le cadre du plan de controle. q) Le prestataire doit preciser dans la convention de service qu'il s'engage a mettre a disposition toutes les informations necessaires a la realisation d'audits de conformite aux dispositions de l'article 28 du [RGPD], menes par le commanditaire ou un tiers mandate. r) Il est recommande que le tiers mandate pour les audits soit un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie." + } + ], + "Checks": [] + }, + { + "Id": "19.2", + "Description": "Les donnees du commanditaire doivent etre stockees et traitees dans des centres de donnees situes sur le territoire de l'Union europeenne. Les politiques de restriction de region doivent etre appliquees.", + "Name": "Localisation des donnees", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "organizations", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et communiquer au commanditaire la localisation du stockage et du traitement des donnees de ce dernier. b) Le prestataire doit stocker et traiter les donnees du commanditaire au sein de l'Union Europeenne. c) Les operations d'administration et de supervision du service doivent etre realisees depuis le territoire de l'Union Europeenne. d) Le prestataire doit stocker et traiter les donnees techniques (identites des beneficiaires et des administrateurs de l'infrastructure technique, donnees manipulees par le Software Defined Network, journaux de l'infrastructure technique, annuaire, certificats, configuration des acces, etc.) au sein de l'Union Europeenne. e) Le prestataire peut realiser des operations de support aux commanditaires depuis un Etat hors de l'Union Europeenne. Il doit documenter la liste des operations qui peuvent etre effectuees par le support au commanditaire depuis un Etat hors de l'Union Europeenne, et les mecanismes permettant d'en assurer le controle d'acces et la supervision depuis l'Union Europeenne." + } + ], + "Checks": [] + }, + { + "Id": "19.3", + "Description": "Les services cloud qualifies SecNumCloud doivent etre operes depuis le territoire de l'Union europeenne.", + "Name": "Regionalisation", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit s'assurer que les interfaces du service accessibles au commanditaire soient au moins disponibles en langue francaise. b) Le prestataire doit fournir un support de premier niveau en langue francaise." + } + ], + "Checks": [] + }, + { + "Id": "19.4", + "Description": "Le prestataire doit definir les conditions de fin de contrat, incluant les modalites de restitution et de suppression des donnees du commanditaire.", + "Name": "Fin de contrat", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) A la fin du contrat liant le prestataire et le commanditaire, que le contrat soit arrive a son terme ou pour toute autre cause, le prestataire doit assurer un effacement securise de l'integralite des donnees du commanditaire. Cet effacement doit faire l'objet d'un preavis formel au commanditaire de la part du prestataire respectant un delai de vingt et un jours calendaires. L'effacement peut etre realise suivant l'une des methodes suivantes, et ce dans un delai precise dans la convention de service : effacement par reecriture complete de tout support ayant heberge ces donnees ; effacement des cles utilisees pour le chiffrement des espaces de stockage du commanditaire decrit au chapitre 10.1 ; recyclage securise, dans les conditions enoncees au chapitre 11.9. b) A la fin du contrat, le prestataire doit supprimer les donnees techniques relatives au commanditaire (annuaire, certificats, configuration des acces, etc.)." + } + ], + "Checks": [] + }, + { + "Id": "19.5", + "Description": "Le prestataire doit mettre en oeuvre des mesures techniques et organisationnelles appropriees pour garantir la protection des donnees a caractere personnel conformement a la reglementation en vigueur.", + "Name": "Protection des donnees a caractere personnel", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit justifier du respect des principes de protection des donnees pour les traitements de donnees a caractere personnel mis en oeuvre pour son propre compte. Il doit justifier au minimum les points suivants : les finalites des traitements determinees, explicites et legitimes ; la tracabilite des activites de traitement pour son compte et celui de son commanditaire ; le fondement licite des traitements ; l'interdiction du detournement de finalite des traitements ; les donnees utilisees respectent le principe du minimum necessaire et suffisant pour les traitements ; ainsi sont adequates, pertinentes et limitees ; la qualite des donnees utilisees pour les traitements maintenue : donnees exactes et tenues a jour ; les durees de conservation definies et limitees. b) Le prestataire doit justifier, pour les traitements de donnees a caractere personnel mis en oeuvre pour son propre compte, du respect des droits des personnes concernees. Il doit justifier au minimum les points suivants : l'information des usagers via un traitement loyal et transparent ; le recueil du consentement des usagers : expres, demontrable et retirable ; la possibilite pour les usagers d'exercer les droits d'acces, de rectification et d'effacement ; la possibilite pour les usagers d'exercer les droits de limitation du traitement, de portabilite et d'opposition. c) Lorsqu'il agit en qualite de sous-traitant au sens de l'article 28 de [RGPD], le prestataire doit apporter assistance et conseil au commanditaire en l'informant si une instruction de ce dernier constitue une violation des regles de protection des donnees." + } + ], + "Checks": [] + }, + { + "Id": "19.6", + "Description": "Le prestataire doit mettre en oeuvre des mesures de protection vis-a-vis du droit extra-europeen, afin de garantir que les donnees du commanditaire ne puissent etre soumises a des legislations extra-europeennes.", + "Name": "Protection vis-a-vis du droit extra-europeen", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le siege statutaire, administration centrale et principal etablissement du prestataire doivent etre etablis au sein d'un Etat membre de l'Union Europeenne. b) Le capital social et les droits de vote dans la societe du prestataire ne doivent pas etre, directement ou indirectement : individuellement detenus a plus de 24% ; et collectivement detenus a plus de 39% ; par des entites tierces possedant leur siege statutaire, administration centrale ou principal etablissement au sein d'un Etat non membre de l'Union europeenne. Ces entites tierces susmentionnees ne peuvent pas individuellement ou collectivement : en vertu d'un contrat ou de clauses statutaires, disposer d'un droit de veto ; en vertu d'un contrat ou de clauses statutaires, designer la majorite des membres des organes d'administration, de direction ou de surveillance du prestataire. c) En cas de recours par le prestataire, dans le cadre des services fournis au commanditaire, aux services d'une societe tierce - y compris un sous-traitant - possedant son siege statutaire, administration centrale ou principal etablissement au sein d'un Etat non membre de l'Union Europeenne ou appartenant ou etant controlee par une societe tierce domiciliee en dehors l'Union Europeenne, cette susdite societe tierce ne doit pas avoir la possibilite technique d'obtenir les donnees operees au travers du service. d) Dans le cadre de l'exigence 19.6.c, toute societe tierce a laquelle le prestataire recourt pour fournir tout ou partie du service rendu au commanditaire, doit garantir au prestataire une autonomie d'exploitation continue dans la fourniture des services d'informatique en nuage qu'il opere ou doit etre qualifie SecNumCloud. e) Le service fourni par le prestataire doit respecter la legislation en vigueur en matiere de droits fondamentaux et les valeurs de l'Union relatives au respect de la dignite humaine, a la liberte, a l'egalite, a la democratie et a l'Etat de droit. f) Le prestataire doit informer formellement le commanditaire, et dans un delai d'un mois, de tout changement juridique, organisationnel ou technique pouvant avoir un impact sur la conformite de la prestation aux exigences du chapitre 19.6." + } + ], + "Checks": [] + } + ] +} diff --git a/prowler/compliance/gcp/c5_gcp.json b/prowler/compliance/gcp/c5_gcp.json index b78afab354..e9d8b15b17 100644 --- a/prowler/compliance/gcp/c5_gcp.json +++ b/prowler/compliance/gcp/c5_gcp.json @@ -136,7 +136,9 @@ "ComplementaryCriteria": "Cloud service customers ensure through suitable controls that the guidelines and requirements for compliance with the contractual agreements with the cloud service provider (i.e., responsibilities, cooperation obligations and interfaces for reporting security incidents) are adequately defined, documented and set up." } ], - "Checks": [] + "Checks": [ + "gemini_api_disabled" + ] }, { "Id": "OIS-03.02B", @@ -7230,7 +7232,9 @@ "ComplementaryCriteria": "" } ], - "Checks": [] + "Checks": [ + "gemini_api_disabled" + ] }, { "Id": "SSO-01.02AC", @@ -7258,7 +7262,9 @@ "ComplementaryCriteria": "" } ], - "Checks": [] + "Checks": [ + "gemini_api_disabled" + ] }, { "Id": "SSO-02.02B", diff --git a/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json b/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json index 420601d810..5638b0c51e 100644 --- a/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json +++ b/prowler/compliance/gcp/fedramp_20x_ksi_low_gcp.json @@ -248,7 +248,8 @@ "compute_public_address_shodan", "cloudsql_instance_automated_backups", "iam_sa_user_managed_key_rotate_90_days", - "iam_service_account_unused" + "iam_service_account_unused", + "gemini_api_disabled" ] }, { diff --git a/prowler/compliance/gcp/hipaa_gcp.json b/prowler/compliance/gcp/hipaa_gcp.json index 6bacc72e9e..37d9838e59 100644 --- a/prowler/compliance/gcp/hipaa_gcp.json +++ b/prowler/compliance/gcp/hipaa_gcp.json @@ -51,7 +51,8 @@ "iam_no_service_roles_at_project_level", "bigquery_dataset_public_access", "bigquery_dataset_cmek_encryption", - "kms_key_rotation_enabled" + "kms_key_rotation_enabled", + "gemini_api_disabled" ] }, { @@ -203,6 +204,36 @@ "cloudstorage_bucket_object_versioning" ] }, + { + "Id": "164_308_b_1", + "Name": "164.308(b)(1) Business associate contracts and other arrangements", + "Description": "A covered entity may permit a business associate to create, receive, maintain, or transmit electronic protected health information on the covered entity's behalf only if the covered entity obtains satisfactory assurances, in accordance with § 164.314(a), that the business associate will appropriately safeguard the information. A covered entity is not required to obtain such satisfactory assurances from a business associate that is a subcontractor.", + "Attributes": [ + { + "ItemId": "164_308_b_1", + "Section": "164.308 Administrative Safeguards", + "Service": "gcp" + } + ], + "Checks": [ + "gemini_api_disabled" + ] + }, + { + "Id": "164_308_b_2", + "Name": "164.308(b)(2)", + "Description": "A business associate may permit a business associate that is a subcontractor to create, receive, maintain, or transmit electronic protected health information on its behalf only if the business associate obtains satisfactory assurances, in accordance with § 164.314(a), that the subcontractor will appropriately safeguard the information.", + "Attributes": [ + { + "ItemId": "164_308_b_2", + "Section": "164.308 Administrative Safeguards", + "Service": "gcp" + } + ], + "Checks": [ + "gemini_api_disabled" + ] + }, { "Id": "164_310_a_1", "Name": "164.310(a)(1) Facility access controls", diff --git a/prowler/compliance/gcp/iso27001_2022_gcp.json b/prowler/compliance/gcp/iso27001_2022_gcp.json index d44d710442..9b814e7769 100644 --- a/prowler/compliance/gcp/iso27001_2022_gcp.json +++ b/prowler/compliance/gcp/iso27001_2022_gcp.json @@ -295,7 +295,8 @@ } ], "Checks": [ - "iam_cloud_asset_inventory_enabled" + "iam_cloud_asset_inventory_enabled", + "gemini_api_disabled" ] }, { @@ -325,7 +326,8 @@ } ], "Checks": [ - "iam_cloud_asset_inventory_enabled" + "iam_cloud_asset_inventory_enabled", + "gemini_api_disabled" ] }, { @@ -340,7 +342,9 @@ "Check_Summary": "The organisation should regularly monitor, review, evaluate and manage change in supplier information security practices and service delivery." } ], - "Checks": [] + "Checks": [ + "gemini_api_disabled" + ] }, { "Id": "A.5.23", @@ -354,7 +358,9 @@ "Check_Summary": "Processes for acquisition, use, management and exit from cloud services should be established in accordance with the organisation’s information security requirements." } ], - "Checks": [] + "Checks": [ + "gemini_api_disabled" + ] }, { "Id": "A.5.24", diff --git a/prowler/compliance/gcp/mitre_attack_gcp.json b/prowler/compliance/gcp/mitre_attack_gcp.json index adf93a32ca..22133caca8 100644 --- a/prowler/compliance/gcp/mitre_attack_gcp.json +++ b/prowler/compliance/gcp/mitre_attack_gcp.json @@ -148,7 +148,8 @@ "iam_sa_user_managed_key_rotate_90_days", "iam_service_account_unused", "apikeys_key_rotated_in_90_days", - "apikeys_api_restrictions_configured" + "apikeys_api_restrictions_configured", + "apikeys_api_restricted_with_gemini_api" ], "Attributes": [ { @@ -1127,7 +1128,9 @@ "iam_role_kms_enforce_separation_of_duties", "iam_role_sa_enforce_separation_of_duties", "iam_sa_no_administrative_privileges", - "iam_sa_no_user_managed_keys" + "iam_sa_no_user_managed_keys", + "apikeys_api_restricted_with_gemini_api", + "gemini_api_disabled" ], "Attributes": [ { @@ -1703,7 +1706,8 @@ "cloudsql_instance_ssl_connections", "dataproc_encrypted_with_cmks_disabled", "dns_rsasha1_in_use_to_key_sign_in_dnssec", - "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "apikeys_api_restricted_with_gemini_api" ], "Attributes": [ { diff --git a/prowler/compliance/gcp/pci_4.0_gcp.json b/prowler/compliance/gcp/pci_4.0_gcp.json index 4a5c4bbb38..e8e30a97e1 100644 --- a/prowler/compliance/gcp/pci_4.0_gcp.json +++ b/prowler/compliance/gcp/pci_4.0_gcp.json @@ -20225,6 +20225,48 @@ } ] }, + { + "Id": "12.8.3.1", + "Description": "Checks that the Gemini API (a.k.a. Generative Language API) is disabled since it is out of scope of GCP's PCI DSS conformance.", + "Name": "Gemini API", + "Checks": [ + "gemini_api_disabled" + ], + "Attributes": [ + { + "Section": "12.8.3: Risk to information assets associated with third-party service provider (TPSP) relationships is managed.", + "Service": "Gemini API" + } + ] + }, + { + "Id": "12.8.4.1", + "Description": "Checks that the Gemini API (a.k.a. Generative Language API) is disabled since it is out of scope of GCP's PCI DSS conformance.", + "Name": "Gemini API", + "Checks": [ + "gemini_api_disabled" + ], + "Attributes": [ + { + "Section": "12.8.4: Risk to information assets associated with third-party service provider (TPSP) relationships is managed.", + "Service": "Gemini API" + } + ] + }, + { + "Id": "12.9.1.1", + "Description": "Checks that the Gemini API (a.k.a. Generative Language API) is disabled since it is out of scope of GCP's PCI DSS conformance.", + "Name": "Gemini API", + "Checks": [ + "gemini_api_disabled" + ], + "Attributes": [ + { + "Section": "12.9.1: Third-party service providers (TPSPs) support their customers’ PCI DSS compliance.", + "Service": "Gemini API" + } + ] + }, { "Id": "A1.1.2.1", "Description": "Checks if a backup vault has an attached IAM policy that prevents deletion of backup recovery points. The rule is NON_COMPLIANT if the Backup Vault does not have IAM policies or has policies lacking a suitable 'Deny' statement for deleting backups.", diff --git a/prowler/compliance/gcp/rbi_cyber_security_framework_gcp.json b/prowler/compliance/gcp/rbi_cyber_security_framework_gcp.json new file mode 100644 index 0000000000..e86aee6e63 --- /dev/null +++ b/prowler/compliance/gcp/rbi_cyber_security_framework_gcp.json @@ -0,0 +1,178 @@ +{ + "Framework": "RBI-Cyber-Security-Framework", + "Name": "Reserve Bank of India (RBI) Cyber Security Framework", + "Version": "", + "Provider": "GCP", + "Description": "The Reserve Bank had prescribed a set of baseline cyber security controls for primary (Urban) cooperative banks (UCBs) in October 2018. On further examination, it has been decided to prescribe a comprehensive cyber security framework for the UCBs, as a graded approach, based on their digital depth and interconnectedness with the payment systems landscape, digital products offered by them and assessment of cyber security risk. The framework would mandate implementation of progressively stronger security measures based on the nature, variety and scale of digital product offerings of banks.", + "Requirements": [ + { + "Id": "annex_i_1_1", + "Name": "Annex I (1.1)", + "Description": "UCBs should maintain an up-to-date business IT Asset Inventory Register containing the following fields, as a minimum: a) Details of the IT Asset (viz., hardware/software/network devices, key personnel, services, etc.), b. Details of systems where customer data are stored, c. Associated business applications, if any, d. Criticality of the IT asset (For example, High/Medium/Low).", + "Attributes": [ + { + "ItemId": "annex_i_1_1", + "Service": "gcp" + } + ], + "Checks": [ + "iam_cloud_asset_inventory_enabled", + "iam_organization_essential_contacts_configured" + ] + }, + { + "Id": "annex_i_1_3", + "Name": "Annex I (1.3)", + "Description": "Appropriately manage and provide protection within and outside UCB/network, keeping in mind how the data/information is stored, transmitted, processed, accessed and put to use within/outside the UCB's network, and level of risk they are exposed to depending on the sensitivity of the data/information.", + "Attributes": [ + { + "ItemId": "annex_i_1_3", + "Service": "gcp" + } + ], + "Checks": [ + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access", + "cloudstorage_bucket_logging_enabled", + "cloudstorage_bucket_versioning_enabled", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "cloudsql_instance_ssl_connections", + "compute_instance_public_ip", + "compute_instance_encryption_with_csek_enabled", + "compute_image_not_publicly_shared", + "kms_key_rotation_enabled", + "kms_key_not_publicly_accessible", + "bigquery_dataset_public_access", + "bigquery_dataset_cmk_encryption" + ] + }, + { + "Id": "annex_i_5_1", + "Name": "Annex I (5.1)", + "Description": "The firewall configurations should be set to the highest security level and evaluation of critical device (such as firewall, network switches, security devices, etc.) configurations should be done periodically.", + "Attributes": [ + { + "ItemId": "annex_i_5_1", + "Service": "compute" + } + ], + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_not_legacy", + "compute_network_default_in_use", + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled", + "dns_dnssec_disabled" + ] + }, + { + "Id": "annex_i_6", + "Name": "Annex I (6)", + "Description": "Put in place systems and processes to identify, track, manage and monitor the status of patches to servers, operating system and application software running at the systems used by the UCB officials (end-users). Implement and update antivirus protection for all servers and applicable end points preferably through a centralised system.", + "Attributes": [ + { + "ItemId": "annex_i_6", + "Service": "gcp" + } + ], + "Checks": [ + "compute_instance_shielded_vm_enabled", + "compute_project_os_login_enabled", + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ] + }, + { + "Id": "annex_i_7_1", + "Name": "Annex I (7.1)", + "Description": "Disallow administrative rights on end-user workstations/PCs/laptops and provide access rights on a 'need to know' and 'need to do' basis.", + "Attributes": [ + { + "ItemId": "annex_i_7_1", + "Service": "iam" + } + ], + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "iam_role_sa_enforce_separation_of_duties", + "iam_role_kms_enforce_separation_of_duties", + "iam_sa_no_user_managed_keys", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access" + ] + }, + { + "Id": "annex_i_7_2", + "Name": "Annex I (7.2)", + "Description": "Passwords should be set as complex and lengthy and users should not use same passwords for all the applications/systems/devices.", + "Attributes": [ + { + "ItemId": "annex_i_7_2", + "Service": "iam" + } + ], + "Checks": [ + "compute_project_os_login_2fa_enabled", + "iam_account_access_approval_enabled" + ] + }, + { + "Id": "annex_i_7_3", + "Name": "Annex I (7.3)", + "Description": "Remote Desktop Protocol (RDP) which allows others to access the computer remotely over a network or over the internet should be always disabled and should be enabled only with the approval of the authorised officer of the UCB. Logs for such remote access shall be enabled and monitored for suspicious activities.", + "Attributes": [ + { + "ItemId": "annex_i_7_3", + "Service": "compute" + } + ], + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_project_os_login_enabled" + ] + }, + { + "Id": "annex_i_7_4", + "Name": "Annex I (7.4)", + "Description": "Implement appropriate (e.g. centralised) systems and controls to allow, manage, log and monitor privileged/super user/administrative access to critical systems (servers/databases, applications, network devices etc.)", + "Attributes": [ + { + "ItemId": "annex_i_7_4", + "Service": "logging" + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "logging_sink_created", + "cloudstorage_audit_logs_enabled", + "compute_loadbalancer_logging_enabled", + "compute_subnet_flow_logs_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ] + }, + { + "Id": "annex_i_12", + "Name": "Annex I (12)", + "Description": "Take periodic back up of the important data and store this data 'off line' (i.e., transferring important files to a storage device that can be detached from a computer/system after copying all the files).", + "Attributes": [ + { + "ItemId": "annex_i_12", + "Service": "gcp" + } + ], + "Checks": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_versioning_enabled", + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_lifecycle_management_enabled", + "compute_snapshot_not_outdated" + ] + } + ] +} diff --git a/prowler/compliance/gcp/secnumcloud_3.2_gcp.json b/prowler/compliance/gcp/secnumcloud_3.2_gcp.json new file mode 100644 index 0000000000..e709545da4 --- /dev/null +++ b/prowler/compliance/gcp/secnumcloud_3.2_gcp.json @@ -0,0 +1,1460 @@ +{ + "Framework": "SecNumCloud", + "Name": "SecNumCloud Referentiel d'Exigences v3.2", + "Version": "3.2", + "Provider": "GCP", + "Description": "The SecNumCloud framework is published by ANSSI (Agence Nationale de la Securite des Systemes d'Information) to qualify cloud service providers operating in France. Version 3.2, dated March 8, 2022, covers IaaS, CaaS, PaaS, and SaaS services with requirements spanning information security policies, access control, cryptography, physical security, operational security, communications security, and data sovereignty protections against extra-European law.", + "Requirements": [ + { + "Id": "5.1", + "Description": "Le prestataire doit definir et appliquer des principes de securite de l'information adaptes a ses activites de fourniture de services cloud.", + "Name": "Principes", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit operer la prestation a l'etat de l'art pour le type d'activite retenu : utiliser des logiciels stables beneficiant d'un suivi des correctifs de securite et parametres de facon a obtenir un niveau de securite optimal. b) Le prestataire doit appliquer le guide d'hygiene informatique de l'ANSSI [HYGIENE], niveau renforce, au systeme d'information du service." + } + ], + "Checks": [] + }, + { + "Id": "5.2", + "Description": "Le prestataire doit definir, faire approuver par la direction, publier et communiquer aux salaries et aux tiers concernes un ensemble de politiques de securite de l'information.", + "Name": "Politique de securite de l'information", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de securite de l'information relative au service. b) La politique de securite de l'information doit identifier les engagements du prestataire quant au respect de la legislation et reglementation nationale en vigueur selon la nature des informations qui pourraient etre confiees par le commanditaire au prestataire ; il revient en revanche in fine au commanditaire de s'assurer du respect des contraintes legales et reglementaires applicables aux donnees qu'il confie effectivement au prestataire. c) La politique de securite de l'information doit notamment couvrir les themes abordes aux chapitres 6 a 19 du present referentiel. d) La direction du prestataire doit approuver formellement la politique de securite de l'information. e) Le prestataire doit reviser annuellement la politique de securite de l'information et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "5.3", + "Description": "Le prestataire doit definir et appliquer un processus d'appreciation des risques de securite de l'information.", + "Name": "Appreciation des risques", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter une appreciation des risques couvrant l'ensemble du perimetre du service. b) Le prestataire doit realiser son appreciation de risques en utilisant une methode documentee garantissant la reproductibilite et comparabilite de la demarche. c) Le prestataire doit prendre en compte dans l'appreciation des risques : la gestion d'informations du commanditaire ayant des besoins de securite differents ; les risques ayant des impacts sur les droits et libertes des personnes concernees en cas d'acces non autorise, de modification non desiree et de disparition de donnees a caractere personnel ; les risques de defaillance des mecanismes de cloisonnement des ressources de l'infrastructure technique (memoire, calcul, stockage, reseau) partagees entre les commanditaires ; les risques lies a l'effacement incomplet ou non securise des donnees stockees sur les espaces de memoire ou de stockage partages entre commanditaires, en particulier lors des reallocations des espaces de memoire et de stockage ; les risques lies a l'exposition des interfaces d'administration sur un reseau public ; les risques d'atteinte a la confidentialite des donnees des commanditaires par des tiers impliques dans la fourniture du service (fournisseurs, sous-traitants, etc.) ; les risques lies aux evenements naturels et sinistres physiques ; les risques lies a la separation des taches (voir 6.2.a) ; les risques lies aux environnements de developpement (voir 14.4.b). d) Le prestataire doit lister, dans un document specifique, les risques residuels lies a l'existence de lois extra-europeennes ayant pour objectif la collecte de donnees ou metadonnees des commanditaires sans leur consentement prealable. e) Le prestataire doit mettre a la disposition du commanditaire, sur demande de celui-ci, les elements d'appreciation des risques lies a la soumission des donnees du commanditaire au droit d'un etat non-membre de l'Union Europeenne. f) Lorsqu'il existe des exigences legales, reglementaires ou sectorielles specifiques liees aux types d'informations confiees par le commanditaire au prestataire, ce dernier doit les prendre en compte dans son appreciation des risques en s'assurant de respecter l'ensemble des exigences du present referentiel d'une part et de ne pas abaisser le niveau de securite etabli par le respect des exigences du present referentiel d'autre part. g) La direction du prestataire doit accepter formellement les risques residuels identifies dans l'appreciation des risques. h) Le prestataire doit reviser annuellement l'appreciation des risques et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "6.1", + "Description": "Le prestataire doit definir et attribuer toutes les responsabilites en matiere de securite de l'information.", + "Name": "Fonctions et responsabilites liees a la securite de l'information", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une organisation interne de la securite pour assurer la definition, la mise en place et le suivi du fonctionnement operationnel de la securite de l'information au sein de son organisation. b) Le prestataire doit designer un responsable de la securite des systemes d'information et un responsable de la securite physique. c) Le prestataire doit definir et attribuer les responsabilites en matiere de securite de l'information pour le personnel implique dans la fourniture du service. d) Le prestataire doit s'assurer apres tout changement majeur pouvant avoir un impact sur le service que l'attribution des responsabilites en matiere de securite de l'information est toujours pertinente. e) Le prestataire doit definir et attribuer les responsabilites en matiere de protection de donnees a caractere personnel, en coherence avec son role dans les traitements de donnees a caractere personnel (responsable de traitement, sous-traitant ou co-responsable). f) Le prestataire doit, lorsqu'il traite un grand nombre de donnees parmi lesquelles figurent des categories particulieres de donnees a caractere personnel telles que definies dans [RGPD], designer un delegue a la protection des donnees. g) Il est recommande que le prestataire, quel que soit le volume de donnees a caractere personnel qu'il traite, designe un delegue a la protection des donnees. h) Le prestataire doit realiser ou contribuer a la realisation d'une analyse d'impact relative a la protection des donnees a caractere personnel lorsque le traitement est susceptible d'engendrer un risque eleve pour les droits et libertes des personnes concernees (traitement de categories particulieres de donnees a caractere personnel telles que definies dans [RGPD], traitement de donnees a grande echelle, etc.). Cette analyse doit comporter une evaluation juridique du respect des principes et droits fondamentaux, ainsi qu'une etude plus technique des mesures techniques mises en oeuvre pour proteger les personnes des risques pour leur vie privee." + } + ], + "Checks": [] + }, + { + "Id": "6.2", + "Description": "Le prestataire doit separer les taches et les domaines de responsabilite incompatibles afin de reduire les possibilites de modification non autorisee ou de mauvais usage des actifs.", + "Name": "Separation des taches", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les risques associes a des cumuls de responsabilites ou de taches, les prendre en compte dans l'appreciation des risques et mettre en oeuvre des mesures de reduction de ces risques." + } + ], + "Checks": [] + }, + { + "Id": "6.3", + "Description": "Le prestataire doit etablir et maintenir des relations appropriees avec les autorites competentes.", + "Name": "Relations avec les autorites", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire mette en place des relations appropriees avec les autorites competentes en matiere de securite de l'information et de donnees a caractere personnel et, le cas echeant, avec les autorites sectorielles selon la nature des informations confiees par le commanditaire au prestataire." + } + ], + "Checks": [] + }, + { + "Id": "6.4", + "Description": "Le prestataire doit etablir et maintenir des relations appropriees avec des groupes de travail specialises, des associations professionnelles ou des forums traitant de la securite.", + "Name": "Relations avec les groupes de travail specialises", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire entretienne des contacts appropries avec des groupes de specialistes ou des sources reconnues, notamment pour prendre en compte de nouvelles menaces et les mesures de securite appropriees pour les contrer." + } + ], + "Checks": [] + }, + { + "Id": "6.5", + "Description": "Le prestataire doit integrer la securite de l'information dans la gestion de projet, quel que soit le type de projet.", + "Name": "La securite de l'information dans la gestion de projet", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter une estimation des risques prealablement a tout projet pouvant avoir un impact sur le service, et ce quelle que soit la nature du projet. b) Dans la mesure ou un projet affecte ou est susceptible d'affecter le niveau de securite du service, le prestataire doit avertir le commanditaire et l'informer par ecrit des impacts potentiels, des mesures mises en place pour reduire ces impacts ainsi que des risques residuels le concernant." + } + ], + "Checks": [] + }, + { + "Id": "7.1", + "Description": "Le prestataire doit s'assurer que les candidats a l'embauche font l'objet de verifications proportionnees aux exigences metier, a la classification des informations accessibles et aux risques identifies.", + "Name": "Selection des candidats", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de verification des informations concernant son personnel conforme aux lois et reglements en vigueur. Ces verifications s'appliquent a toute personne impliquee dans la fourniture du service et doivent etre proportionnelles a la sensibilite ou a la specificite des informations du commanditaire confiees au prestataire ainsi qu'aux risques identifies. b) Pour les personnels disposant de privileges d'administration eleves sur les composants logiciels et materiels de l'infrastructure, le prestataire doit renforcer les verifications destinees a verifier que les antecedents de ceux-ci ne sont pas incompatibles avec l'exercice de leurs fonctions. Il est entendu par des privileges d'administration eleves, des actions permettant l'elevation de privileges ou la possibilite de realiser des actions sans traces techniques ou de desactiver, alterer les traces techniques." + } + ], + "Checks": [] + }, + { + "Id": "7.2", + "Description": "Les accords contractuels avec les salaries et les sous-traitants doivent preciser leurs responsabilites et celles du prestataire en matiere de securite de l'information.", + "Name": "Conditions d'embauche", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit disposer d'une charte d'ethique integree au reglement interieur, prevoyant notamment que : les prestations sont realisees avec loyaute, discretion et impartialite et dans des conditions de confidentialite des informations traitees ; les personnels ne recourent qu'aux methodes, outils et techniques valides par le prestataire ; les personnels s'engagent a ne pas divulguer d'informations a un tiers, meme anonymisees et decontextualisees, obtenues ou generees dans le cadre de la prestation sauf autorisation formelle et ecrite du commanditaire ; les personnels s'engagent a signaler au prestataire tout contenu manifestement illicite decouvert pendant la prestation ; les personnels s'engagent a respecter la legislation et la reglementation nationale en vigueur et les bonnes pratiques liees a leurs activites. b) Le prestataire doit faire signer la charte d'ethique a l'ensemble des personnes impliquees dans la fourniture du service. c) Le prestataire doit introduire, dans le contrat de travail des personnels disposant de privileges d'administration eleves sur les composants et materiels de l'infrastructure du service, un engagement de responsabilite avec un renvoi aux clauses du code du travail sur la protection du secret des affaires et de la propriete intellectuelle. Il est entendu par des privileges d'administration eleves, des actions permettant l'elevation de privileges ou la possibilite de realiser des actions sans traces techniques ou de desactiver, alterer les traces techniques. d) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible le reglement interieur et la charte d'ethique." + } + ], + "Checks": [] + }, + { + "Id": "7.3", + "Description": "Les salaries du prestataire et, le cas echeant, les sous-traitants doivent suivre un programme de sensibilisation et de formation adapte et regulier concernant la securite de l'information.", + "Name": "Sensibilisation, apprentissage et formations a la securite de l'information", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit sensibiliser a la securite de l'information et aux risques lies a la protection des donnees l'ensemble des personnes impliquees dans la fourniture du service. Il doit leur communiquer les mises a jour des politiques et procedures pertinentes dans le cadre de leurs missions. b) Le prestataire doit documenter et mettre en oeuvre un plan de formation concernant la securite de l'information adapte au service et aux missions des personnels. c) Le responsable de la securite des systemes d'information du prestataire doit valider formellement le plan de formation concernant la securite de l'information." + } + ], + "Checks": [] + }, + { + "Id": "7.4", + "Description": "Le prestataire doit mettre en place un processus disciplinaire formel et communique pour prendre des mesures a l'encontre des salaries ayant enfreint les regles de securite de l'information.", + "Name": "Processus disciplinaire", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus disciplinaire applicable a l'ensemble des personnes impliquees dans la fourniture du service ayant enfreint la politique de securite. b) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible les sanctions encourues en cas d'infraction a la politique de securite." + } + ], + "Checks": [] + }, + { + "Id": "7.5", + "Description": "Les responsabilites et les obligations en matiere de securite de l'information qui restent valables apres un changement ou une rupture du contrat de travail doivent etre definies, communiquees au salarie ou au sous-traitant et appliquees.", + "Name": "Rupture, terme ou modification du contrat de travail", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit definir et attribuer les roles et les responsabilites relatives a la rupture, au terme ou a la modification de tout contrat avec une personne impliquee dans la fourniture du service." + } + ], + "Checks": [] + }, + { + "Id": "8.1", + "Description": "Le prestataire doit identifier les actifs associes a l'information et aux moyens de traitement de l'information et doit etablir et tenir a jour un inventaire de ces actifs.", + "Name": "Inventaire et propriete des actifs", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "iam", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit tenir a jour l'inventaire de l'ensemble des equipements mettant en oeuvre le service. Cet inventaire doit preciser pour chaque equipement : les informations d'identification de l'equipement (noms, adresses IP, adresses MAC, etc.) ; la fonction de l'equipement ; le modele de l'equipement ; la localisation de l'equipement ; le proprietaire de l'equipement ; le besoin de securite des informations (au sens du chapitre 8.3). b) Le prestataire doit tenir a jour l'inventaire de l'ensemble des logiciels mettant en oeuvre le service. Cet inventaire doit identifier pour chaque logiciel, sa version et les equipements sur lesquels le logiciel est installe. c) Le prestataire doit s'assurer de la validite des licences des logiciels tout au long de la prestation." + } + ], + "Checks": [ + "iam_cloud_asset_inventory_enabled" + ] + }, + { + "Id": "8.2", + "Description": "Les salaries et les utilisateurs de tiers doivent restituer tous les actifs du prestataire en leur possession au terme de la periode d'emploi, du contrat ou de l'accord.", + "Name": "Restitution des actifs", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de restitution des actifs permettant de s'assurer que chaque personne impliquee dans la fourniture du service restitue l'ensemble des actifs en sa possession a la fin de sa periode d'emploi ou de son contrat." + } + ], + "Checks": [] + }, + { + "Id": "8.3", + "Description": "Les besoins de protection de la confidentialite, de l'integrite et de la disponibilite de l'information doivent etre identifies.", + "Name": "Identification des besoins de securite de l'information", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les differents besoins de securite des informations relatives au service. b) Lorsque le commanditaire confie au prestataire des donnees soumises a des contraintes legales, reglementaires ou sectorielles specifiques, le prestataire doit identifier les besoins de securite specifiques associes a ces contraintes." + } + ], + "Checks": [] + }, + { + "Id": "8.4", + "Description": "Un ensemble de procedures appropriees pour le marquage et la manipulation de l'information doit etre elabore et mis en oeuvre conformement au plan de classification adopte par le prestataire.", + "Name": "Marquage et manipulation de l'information", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire documente et mette en oeuvre une procedure pour le marquage et la manipulation de toutes les informations participant a la delivrance du service, conformement a son besoin de securite defini au chapitre 8.3." + } + ], + "Checks": [] + }, + { + "Id": "8.5", + "Description": "Des procedures de gestion des supports amovibles doivent etre mises en oeuvre conformement au plan de classification adopte par le prestataire.", + "Name": "Gestion des supports amovibles", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure pour la gestion des supports amovibles, conformement au besoin de securite defini au chapitre 8.3. Lorsque des supports amovibles sont utilises sur l'infrastructure technique ou pour des taches d'administration, ces supports doivent etre dedies a un usage." + } + ], + "Checks": [] + }, + { + "Id": "9.1", + "Description": "Une politique de controle d'acces doit etre etablie, documentee et revue en se basant sur les exigences metier et les exigences de securite de l'information. Les regles de controle d'acces et les droits pour chaque utilisateur ou groupe d'utilisateurs doivent etre clairement definis.", + "Name": "Politiques et controle d'acces", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de controle d'acces sur la base du resultat de son appreciation des risques et du partage des responsabilites. b) Le prestataire doit reviser annuellement la politique de controle d'acces et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "iam_role_sa_enforce_separation_of_duties" + ] + }, + { + "Id": "9.2", + "Description": "Un processus formel d'enregistrement et de desinscription des utilisateurs doit etre mis en oeuvre pour permettre l'attribution des droits d'acces.", + "Name": "Enregistrement et desinscription des utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure d'enregistrement et de desinscription des utilisateurs s'appuyant sur une interface de gestion des comptes et des droits d'acces. Cette procedure doit indiquer quelles donnees doivent etre supprimees au depart d'un utilisateur. b) Le prestataire doit attribuer des comptes nominatifs lors de l'enregistrement des utilisateurs places sous sa responsabilite. c) Le prestataire doit mettre en oeuvre des moyens permettant de s'assurer que la desinscription d'un utilisateur entraine la suppression de tous ses acces aux ressources du systeme d'information du service, ainsi que la suppression de ses donnees conformement a la procedure d'enregistrement et de desinscription (voir exigence 9.2 a))." + } + ], + "Checks": [ + "iam_sa_user_managed_key_unused", + "iam_service_account_unused" + ] + }, + { + "Id": "9.3", + "Description": "Un processus formel de gestion des droits d'acces doit etre mis en oeuvre pour controler l'attribution des droits d'acces a tous les types d'utilisateurs et a tous les systemes et services.", + "Name": "Gestion des droits d'acces", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'assurer l'attribution, la modification et le retrait de droits d'acces aux ressources du systeme d'information du service. b) Le prestataire doit mettre a la disposition de ses commanditaires les outils et les moyens qui permettent une differenciation des roles des utilisateurs du service, par exemple suivant leur role fonctionnel. c) Le prestataire doit tenir a jour l'inventaire des utilisateurs sous sa responsabilite disposant de droits d'administration sur les ressources du systeme d'information du service. d) Le prestataire doit etre en mesure de fournir, pour une ressource donnee mettant en oeuvre le service, la liste de tous les utilisateurs y ayant acces, qu'ils soient sous la responsabilite du prestataire ou du commanditaire ainsi que les droits d'acces qui leurs ont ete attribues. e) Le prestataire doit etre en mesure de fournir, pour un utilisateur donne, qu'ils soient sous la responsabilite du prestataire ou du commanditaire, la liste de tous ses droits d'acces sur les differents elements du systeme d'information du service. f) Le prestataire doit definir une liste de droits d'acces incompatibles entre eux. Il doit s'assurer, lors de l'attribution de droits d'acces a un utilisateur qu'il ne possede pas de droits d'acces incompatibles entre eux au titre de la liste precedemment etablie. g) Le prestataire doit inclure dans la procedure de gestion des droits d'acces les actions de revocation ou de suspension des droits de tout utilisateur." + } + ], + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "iam_role_sa_enforce_separation_of_duties", + "iam_role_kms_enforce_separation_of_duties" + ] + }, + { + "Id": "9.4", + "Description": "Les proprietaires d'actifs doivent verifier les droits d'acces des utilisateurs a intervalles reguliers.", + "Name": "Revue des droits d'acces utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit reviser annuellement les droits d'acces des utilisateurs sur son perimetre de responsabilite. b) Le prestataire doit mettre a disposition du commanditaire un outil facilitant la revue des droits d'acces des utilisateurs places sous la responsabilite de ce dernier. c) Le prestataire doit reviser trimestriellement la liste des utilisateurs sur son perimetre de responsabilite pouvant utiliser les comptes techniques mentionnes dans l'exigence 9.2 b)." + } + ], + "Checks": [ + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused", + "iam_service_account_unused" + ] + }, + { + "Id": "9.5", + "Description": "L'attribution et l'utilisation des informations secretes d'authentification doivent etre gerees dans le cadre d'un processus de gestion formel incluant une politique de mot de passe robuste et l'utilisation de l'authentification multi-facteur.", + "Name": "Gestion des authentifications des utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "compute", + "Type": "Automated", + "Comment": "a) Le prestataire doit formaliser et mettre en oeuvre des procedures de gestion de l'authentification des utilisateurs. En accord avec les exigences du chapitre 10, celles-ci doivent notamment porter sur : la gestion des moyens d'authentification (emission et reinitialisation de mot de passe, mise a jour des CRL et import des certificats racines en cas d'utilisation de certificats, etc.) ; la mise en place des moyens permettant une authentification a multiples facteurs afin de repondre aux differents cas d'usage du referentiel ; les systemes qui generent des mots de passe ou verifient leur robustesse, lorsqu'une authentification par mot de passe est utilisee. Ils doivent suivre les recommandations de [G_AUTH]. b) Tout mecanisme d'authentification doit prevoir le blocage d'un compte apres un nombre limite de tentatives infructueuses. c) Dans le cadre d'un service SaaS, le prestataire doit proposer a ses commanditaires des moyens d'authentification a multiples facteurs pour l'acces des utilisateurs finaux. d) Lorsque des comptes techniques, non nominatifs, sont necessaires, le prestataire doit mettre en place des mesures obligeant les utilisateurs a s'authentifier avec leur compte nominatif avant de pouvoir acceder a ces comptes techniques." + } + ], + "Checks": [ + "compute_project_os_login_enabled", + "compute_project_os_login_2fa_enabled", + "apikeys_key_rotated_in_90_days" + ] + }, + { + "Id": "9.6", + "Description": "L'acces aux interfaces d'administration du service cloud doit etre restreint et protege par des mecanismes d'authentification forte, incluant l'utilisation de dispositifs MFA materiels pour les comptes a privileges.", + "Name": "Acces aux interfaces d'administration", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "compute", + "Type": "Automated", + "Comment": "a) Les comptes d'administration sous la responsabilite du prestataire doivent etre geres a l'aide d'outils et d'annuaires distincts de ceux utilises pour la gestion des comptes utilisateurs places sous la responsabilite du commanditaire. b) Les interfaces d'administration mises a disposition des commanditaires doivent etre distinctes des interfaces d'administration utilisees par le prestataire. c) Les interfaces d'administration mises a disposition des commanditaires ne doivent permettre aucune connexion avec des comptes d'administrateurs sous la responsabilite du prestataire. d) Les interfaces d'administration utilisees par le prestataire ne doivent pas etre accessibles a partir d'un reseau public et ainsi ne doivent permettre aucune connexion des utilisateurs sous la responsabilite du commanditaire. e) Si des interfaces d'administration sont mises a disposition des commanditaires avec un acces via un reseau public, les flux d'administration doivent etre authentifies et chiffres avec des moyens en accord avec les exigences du chapitre 10.2. f) Le prestataire doit mettre en place un systeme d'authentification multifacteur fort pour l'acces : aux interfaces d'administration utilisees par le prestataire ; aux interfaces d'administration dediees aux commanditaires. g) Dans le cadre d'un service SaaS, les interfaces d'administration mises a disposition des commanditaires doivent etre differenciees des interfaces permettant l'acces des utilisateurs finaux. h) Des lors qu'une interface d'administration est accessible depuis un reseau public, le processus d'authentification doit avoir lieu avant toute interaction entre l'utilisateur et l'interface en question. i) Lorsque le prestataire utilise un service de type IaaS comme socle d'un autre type de service (CaaS, PaaS ou SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service IaaS. j) Lorsque le prestataire utilise un service de type CaaS comme socle d'un autre type de service (PaaS ou SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service CaaS. k) Lorsque le prestataire utilise un service de type PaaS comme socle d'un autre type de service (typiquement SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service PaaS." + } + ], + "Checks": [ + "compute_project_os_login_2fa_enabled", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_instance_block_project_wide_ssh_keys_disabled", + "iam_account_access_approval_enabled", + "gke_cluster_no_default_service_account" + ] + }, + { + "Id": "9.7", + "Description": "L'acces a l'information et aux fonctions d'application des systemes doit etre restreint conformement a la politique de controle d'acces. Les ressources doivent etre protegees contre tout acces public non autorise.", + "Name": "Restriction des acces a l'information", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "compute", + "Type": "Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre des mesures de cloisonnement appropriees entre ses commanditaires. b) Le prestataire doit mettre en oeuvre des mesures de cloisonnement appropriees entre le systeme d'information du service et ses autres systemes d'information (bureautique, informatique de gestion, gestion technique du batiment, controle d'acces physique, etc.). c) Le prestataire doit concevoir, developper, configurer et deployer le systeme d'information du service en assurant au moins un cloisonnement entre d'une part l'infrastructure technique et d'autre part les equipements necessaires a l'administration des services et des ressources qu'elle heberge. d) Dans le cadre du support technique, si les actions necessaires au diagnostic et a la resolution d'un probleme rencontre par un commanditaire necessitent un acces aux donnees du commanditaire, alors le prestataire doit : n'autoriser l'acces aux donnees du commanditaire qu'apres consentement explicite du commanditaire ; verifier que la personne a qui l'acces doit etre autorise a satisfait aux verifications de l'exigence 7.1.b ; dans le cas d'une intervention realisee a distance par une personne localisee hors de l'Union Europeenne, mettre en oeuvre une passerelle securisee (poste de rebond) par laquelle la personne devra se connecter et permettant une supervision (autorisation ou interdiction des actions, demandes d'explications, etc.) en temps reel, par une personne ayant elle-meme satisfait aux verifications de l'exigence 7.1.b ; considerer les actions menees, une fois l'acces autorise, comme des actions d'administration et les journaliser comme telles ; supprimer l'autorisation d'acces aux donnees du commanditaire au terme de ces actions." + } + ], + "Checks": [ + "compute_network_default_in_use", + "compute_instance_public_ip", + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "cloudsql_instance_private_ip_assignment", + "bigquery_dataset_public_access", + "kms_key_not_publicly_accessible", + "compute_image_not_publicly_shared" + ] + }, + { + "Id": "10.1", + "Description": "Les donnees stockees dans le cadre du service cloud doivent etre chiffrees au repos en utilisant des algorithmes et des longueurs de cle conformes a l'etat de l'art.", + "Name": "Chiffrement des donnees stockees", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "compute", + "Type": "Automated", + "Comment": "a) Le prestataire doit definir et mettre en oeuvre un mecanisme de chiffrement empechant la recuperation des donnees des commanditaires en cas de reallocation d'une ressource ou de recuperation du support physique. Dans le cas d'un service IaaS ou CaaS, cet objectif pourra par exemple etre atteint par un chiffrement du disque ou du systeme de fichier, lorsque le protocole d'acces en mode fichiers garantit que seuls des blocs vides peuvent etre alloues, ou par un chiffrement par volume dans le cas d'un acces en mode bloc, avec au moins une cle par commanditaire. Dans le cas d'un service PaaS ou SaaS, cet objectif pourra etre atteint en utilisant un chiffrement applicatif dans le perimetre du prestataire, avec au moins une cle par commanditaire. b) Le prestataire doit utiliser une methode de chiffrement des donnees respectant les regles de [CRYPTO_B1]. c) Il est recommande d'utiliser une methode de chiffrement des donnees respectant les recommandations de [CRYPTO_B1]. d) Le prestataire doit mettre en place un chiffrement des donnees sur les supports amovibles et les supports de sauvegarde amenes a quitter le perimetre de securite physique du systeme d'information du service (au sens du chapitre 10), en fonction du besoin de securite des donnees (voir chapitre 8.3)." + } + ], + "Checks": [ + "compute_instance_encryption_with_csek_enabled", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "dataproc_encrypted_with_cmks_disabled" + ] + }, + { + "Id": "10.2", + "Description": "Les flux de donnees entre les composants du service cloud et entre le service et les commanditaires doivent etre chiffres en transit en utilisant des protocoles et des algorithmes conformes a l'etat de l'art.", + "Name": "Chiffrement des flux", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "cloudsql", + "Type": "Partially Automated", + "Comment": "a) Lorsque le prestataire met en oeuvre un mecanisme de chiffrement des flux reseau, celui-ci doit respecter les regles de [CRYPTO_B1]. b) Lorsque le prestataire met en oeuvre un mecanisme de chiffrement des flux reseau, il est recommande que celui-ci respecte les recommandations de [CRYPTO_B1]. c) Si le protocole TLS est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_TLS]. d) Si le protocole IPsec est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_IPSEC]. e) Si le protocole SSH est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_SSH]." + } + ], + "Checks": [ + "cloudsql_instance_ssl_connections" + ] + }, + { + "Id": "10.3", + "Description": "Les mots de passe doivent etre stockes sous forme hachee en utilisant des algorithmes robustes conformes a l'etat de l'art et les politiques de mot de passe doivent imposer des exigences de complexite adequates.", + "Name": "Hachage des mots de passe", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "iam", + "Type": "Manual", + "Comment": "a) Le prestataire ne doit stocker que l'empreinte des mots de passe des utilisateurs et des comptes techniques. b) Le prestataire doit mettre en oeuvre une fonction de hachage respectant les regles de [CRYPTO_B1]. c) Il est recommande que le prestataire mette en oeuvre une fonction de hachage respectant les recommandations de [CRYPTO_B1]. d) Le prestataire doit generer les empreintes des mots de passe avec une fonction de hachage associee a l'utilisation d'un sel cryptographique respectant les regles de [CRYPTO_B1]." + } + ], + "Checks": [] + }, + { + "Id": "10.4", + "Description": "Des mecanismes de non-repudiation doivent etre mis en oeuvre pour assurer la tracabilite des actions effectuees sur le service cloud, incluant la validation de l'integrite des journaux.", + "Name": "Non repudiation", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "cloudstorage", + "Type": "Partially Automated", + "Comment": "a) Lorsque le prestataire met en oeuvre un mecanisme de signature electronique, celui-ci doit respecter les regles de [CRYPTO_B1]. b) Lorsque le prestataire met en oeuvre un mecanisme de signature electronique, il est recommande que celui-ci respecte les recommandations de [CRYPTO_B1]." + } + ], + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "10.5", + "Description": "Les secrets cryptographiques (cles, certificats, mots de passe) doivent etre geres de maniere securisee tout au long de leur cycle de vie, incluant la generation, le stockage, la distribution, la rotation et la destruction.", + "Name": "Gestion des secrets", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "kms", + "Type": "Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre des cles cryptographiques respectant les regles de [CRYPTO_B2]. b) Il est recommande que le prestataire mette en oeuvre des cles cryptographiques respectant les recommandations de [CRYPTO_B2]. c) Le prestataire doit proteger l'acces aux cles cryptographiques et autres secrets utilises pour le chiffrement des donnees par un moyen adapte : conteneur de securite (logiciel ou materiel) ou support disjoint. d) Le prestataire doit proteger l'acces aux cles cryptographiques et autres secrets utilises pour les taches d'administration par un conteneur de securite adapte, logiciel ou materiel." + } + ], + "Checks": [ + "kms_key_rotation_enabled", + "kms_key_not_publicly_accessible", + "iam_sa_no_user_managed_keys", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused", + "apikeys_key_rotated_in_90_days", + "apikeys_api_restrictions_configured" + ] + }, + { + "Id": "10.6", + "Description": "Les racines de confiance (certificats racine, autorites de certification) utilisees dans le cadre du service cloud doivent etre gerees de maniere securisee. Les certificats doivent etre valides et utiliser des algorithmes de cle robustes.", + "Name": "Racines de confiance", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "dns", + "Type": "Partially Automated", + "Comment": "a) Sur l'infrastructure technique, le prestataire doit utiliser exclusivement des certificats de cle publique issus d'une autorite de certification d'un Etat membre de l'Union Europeenne (les ceremonies de generation des cles maitresses doivent avoir lieu dans un pays membre de l'Union Europeenne et en presence du prestataire)." + } + ], + "Checks": [ + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ] + }, + { + "Id": "11.1", + "Description": "Des perimetres de securite doivent etre definis et utilises pour proteger les zones contenant des informations sensibles ou critiques et les moyens de traitement de l'information.", + "Name": "Perimetres de securite physique", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des perimetres de securite, incluant le marquage des zones et les differents moyens de limitation et de controle des acces. b) Le prestataire doit distinguer des zones publiques, des zones privees et des zones sensibles. 11.1.1. Zones publiques : a) Les zones publiques sont accessibles a tous dans les limites de la propriete du prestataire. Le prestataire ne doit heberger aucune ressource devolue au service ou permettant d'acceder a des composantes de celui-ci dans les zones publiques. 11.1.2. Zones privees : a) Les zones privees peuvent heberger : les plateformes et moyens de developpement du service ; les postes d'administration, d'exploitation et de supervision ; les locaux a partir desquels le prestataire opere. 11.1.3. Zones sensibles : a) Les zones sensibles sont reservees a l'hebergement du systeme d'information de production du service hors postes d'administration, d'exploitation et de supervision." + } + ], + "Checks": [] + }, + { + "Id": "11.2", + "Description": "Les zones securisees doivent etre protegees par des controles d'acces physiques adequats pour s'assurer que seul le personnel autorise est admis.", + "Name": "Controle d'acces physique", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "11.2.1. Zones privees : a) Le prestataire doit proteger les zones privees contre les acces non autorises. Pour ce faire, il doit mettre en oeuvre un controle d'acces physique reposant au moins sur un facteur personnel : la connaissance d'un secret, la detention d'un objet ou la biometrie. b) Il est recommande que le prestataire respecte les recommandations de [G_CVAP] pour mettre en oeuvre du controle d'acces physique. c) Le prestataire doit definir et documenter des mesures d'acces physique derogatoires en cas d'urgence. d) Le prestataire doit afficher a l'entree des zones privees un avertissement relatif aux limites et conditions d'acces a ces zones. e) Le prestataire doit definir et documenter les plages horaires et conditions d'acces aux zones privees en fonction des profils des intervenants. f) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de s'assurer que les visiteurs sont systematiquement accompagnes par le prestataire lors de leurs acces et sejours en zone privee. Le prestataire doit conserver une trace de l'identite des visiteurs conformement a la legislation et reglementation en vigueur. g) En cas d'intervention (actions de diagnostic, de maintenance, ou d'administration) en zone privee par un tiers visiteur, le prestataire doit faire superviser (suivre, autoriser, interdire, questionner) les actions par un personnel ayant satisfait aux verifications de l'exigence 7.1.b. h) Le prestataire doit documenter et mettre en oeuvre des mecanismes de surveillance et de detection des acces non autorises aux zones privees. 11.2.2. Zones sensibles : a) Le prestataire doit proteger les zones sensibles contre les acces non autorises. Pour ce faire, il doit mettre en oeuvre un controle d'acces physique reposant au moins sur deux facteurs personnels : la connaissance d'un secret, la detention d'un objet ou la biometrie. b) Il est recommande que le prestataire respecte les recommandations de [G_CVAP] pour la mise en oeuvre du controle d'acces physique. c) Le prestataire doit definir et documenter des mesures d'acces physique derogatoires en cas d'urgence. d) Le prestataire doit afficher a l'entree des zones sensibles un avertissement relatif aux limites et conditions d'acces a ces zones. e) Le prestataire doit definir et documenter les plages horaires et conditions d'acces aux zones sensibles en fonction des profils des intervenants. f) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de s'assurer que les visiteurs sont systematiquement accompagnes par le prestataire lors de leurs acces et sejours en zone sensible. Le prestataire doit conserver une trace de l'identite des visiteurs conformement a la legislation et reglementation en vigueur. g) En cas d'intervention (actions de diagnostic, de maintenance, ou d'administration) en zone sensible par un tiers visiteur, le prestataire doit faire superviser (suivre, autoriser, interdire, questionner) les actions par un personnel ayant satisfait aux verifications de l'exigence 7.1.b. h) Le prestataire doit documenter et mettre en oeuvre des mecanismes de surveillance et de detection des acces non autorises aux zones sensibles. i) Le prestataire doit mettre en place une journalisation des acces physiques aux zones sensibles. Il doit effectuer une revue de ces journaux au moins mensuellement. j) Le prestataire doit mettre en oeuvre les moyens garantissant qu'aucun acces direct n'existe entre une zone publique et une zone sensible." + } + ], + "Checks": [] + }, + { + "Id": "11.3", + "Description": "Des mesures de protection contre les menaces exterieures et environnementales, telles que les catastrophes naturelles, les attaques malveillantes ou les accidents, doivent etre concues et appliquees.", + "Name": "Protection contre les menaces exterieures et environnementales", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de minimiser les risques inherents aux sinistres physiques (incendie, degat des eaux, etc.) et naturels (risques climatiques, inondations, seismes, etc.). b) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de limiter les risques de depart et de propagation de feu ainsi que les risques de degat des eaux. c) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de prevenir et limiter les consequences d'une coupure d'alimentation electrique et permettre une reprise du service conformement aux exigences de disponibilite du service definies dans la convention de service. d) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de maintenir des conditions de temperature et d'humidite adaptees aux equipements. De plus, il doit mettre en oeuvre des mesures permettant de prevenir les pannes de climatisation et d'en limiter les consequences. e) Le prestataire doit documenter et mettre en oeuvre des controles et tests reguliers des equipements de detection et de protection physique." + } + ], + "Checks": [] + }, + { + "Id": "11.4", + "Description": "Des mesures de securite physique pour le travail dans les zones privees et sensibles doivent etre concues et appliquees.", + "Name": "Travail dans les zones privees et sensibles", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit integrer les elements de securite physique dans la politique de securite et l'appreciation des risques conformement au niveau de securite requis par la categorie de la zone. b) Le prestataire doit documenter et mettre en oeuvre des procedures relatives au travail en zones privees et sensibles. Il doit communiquer ces procedures aux intervenants concernes." + } + ], + "Checks": [] + }, + { + "Id": "11.5", + "Description": "Les points d'acces tels que les zones de livraison et de chargement et les autres points par lesquels des personnes non autorisees peuvent penetrer dans les locaux doivent etre controles.", + "Name": "Zones de livraison et de chargement", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Les zones de livraison et de chargement et les autres points par lesquels des personnes non autorisees peuvent penetrer dans les locaux sans etre accompagnees sont considerees comme des zones publiques. b) Le prestataire doit isoler les points d'acces de ces zones vers les zones privees et sensibles, de facon a eviter les acces non autorises, ou a defaut, implementer des mesures compensatoires permettant d'assurer le meme niveau de securite." + } + ], + "Checks": [] + }, + { + "Id": "11.6", + "Description": "Le cablage electrique et de telecommunications transportant des donnees ou supportant des services d'information doit etre protege contre les interceptions, les interferences ou les dommages.", + "Name": "Securite du cablage", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de proteger le cablage electrique et de telecommunication des dommages physiques et des possibilites d'interception. b) Le prestataire doit etablir et tenir a jour un plan de cablage. c) Il est recommande que le prestataire mette en oeuvre des mesures permettant d'identifier les cables (par exemple code couleur, etiquette, etc.) afin d'en faciliter l'exploitation et limiter les erreurs de manipulation." + } + ], + "Checks": [] + }, + { + "Id": "11.7", + "Description": "Les materiels doivent etre entretenus correctement pour garantir leur disponibilite permanente et leur integrite.", + "Name": "Maintenance des materiels", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de s'assurer que les conditions d'installation, de maintenance et d'entretien des equipements du systeme d'information du service heberges en zones privees et sensibles sont compatibles avec les exigences de confidentialite et de disponibilite du service definies dans la convention de service. b) Le prestataire doit souscrire des contrats de maintenance permettant de disposer des mises a jour de securite des logiciels installes sur les equipements du systeme d'information du service. c) Le prestataire doit s'assurer que les supports ne peuvent etre retournes a un tiers que si les donnees du commanditaire y sont stockees chiffrees conformement au chapitre 10.1 ou ont prealablement ete detruites a l'aide d'un mecanisme d'effacement securise par reecriture de motifs aleatoires. d) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de s'assurer que les conditions d'installation, de maintenance et d'entretien des equipements techniques annexes (alimentation electrique, climatisation, incendie, etc.) sont compatibles avec les exigences de disponibilite du service definies dans la convention de service." + } + ], + "Checks": [] + }, + { + "Id": "11.8", + "Description": "Les materiels, les informations ou les logiciels ne doivent pas etre sortis des locaux du prestataire sans autorisation prealable.", + "Name": "Sortie des actifs", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de transfert hors site de donnees du commanditaire, equipements et logiciels. Cette procedure doit necessiter que la direction du prestataire donne son autorisation ecrite. Dans tous les cas, le prestataire doit mettre en oeuvre les moyens permettant de garantir que le niveau de protection en confidentialite et en integrite des actifs durant leur transport est equivalent a celui sur site." + } + ], + "Checks": [] + }, + { + "Id": "11.9", + "Description": "Tous les composants des equipements contenant des supports de stockage doivent etre verifies pour s'assurer que toute donnee sensible et tout logiciel sous licence ont ete supprimes ou ecrases de facon securisee avant leur mise au rebut ou leur reutilisation.", + "Name": "Recyclage securise du materiel", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des moyens permettant d'effacer de maniere securisee par reecriture de motifs aleatoires tout support de donnees mis a disposition d'un commanditaire. Si l'espace de stockage est chiffre dans le cadre de l'exigence 10.1.a), l'effacement peut etre realise par un effacement securise de la cle de chiffrement." + } + ], + "Checks": [] + }, + { + "Id": "11.10", + "Description": "Le materiel en attente d'utilisation doit etre protege de maniere adequate.", + "Name": "Materiel en attente d'utilisation", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de protection du materiel en attente d'utilisation." + } + ], + "Checks": [] + }, + { + "Id": "12.1", + "Description": "Les procedures d'exploitation doivent etre documentees et mises a disposition de tous les utilisateurs concernes.", + "Name": "Procedures d'exploitation documentees", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter les procedures d'exploitation, les tenir a jour et les rendre accessibles au personnel concerne." + } + ], + "Checks": [] + }, + { + "Id": "12.2", + "Description": "Les changements apportes au systeme d'information du prestataire, aux processus metier, aux moyens de traitement de l'information et aux systemes qui ont une incidence sur la securite de l'information doivent etre geres.", + "Name": "Gestion des changements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de gestion des changements apportes aux systemes et moyens de traitement de l'information. b) Le prestataire doit documenter et mettre en oeuvre une procedure permettant, en cas d'operations realisees par le prestataire et pouvant avoir un impact sur la securite ou la disponibilite du service, de communiquer au plus tot a l'ensemble de ses commanditaires les informations suivantes : la date et l'heure programmees du debut et de la fin des operations ; la nature des operations ; les impacts sur la securite ou la disponibilite du service ; le contact au sein du prestataire. c) Dans le cadre d'un service PaaS, le prestataire doit informer au plus tot le commanditaire de toute modification a venir sur des elements logiciels sous sa responsabilite des lors que la compatibilite complete ne peut etre assuree. d) Le prestataire doit informer au plus tot le commanditaire de toute modification a venir sur les elements du service des lors qu'elle est susceptible d'occasionner une perte de fonctionnalite pour le commanditaire." + } + ], + "Checks": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled" + ] + }, + { + "Id": "12.3", + "Description": "Les environnements de developpement, de test et d'exploitation doivent etre separes pour reduire les risques d'acces non autorise ou de changements non souhaites dans l'environnement d'exploitation.", + "Name": "Separation des environnements de developpement, de test et d'exploitation", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "organizations", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de separer physiquement les environnements lies a la production du service des autres environnements, dont les environnements de developpement." + } + ], + "Checks": [] + }, + { + "Id": "12.4", + "Description": "Des mesures de detection, de prevention et de recuperation conjuguees a une sensibilisation des utilisateurs doivent etre mises en oeuvre pour proteger le systeme d'information contre les codes malveillants.", + "Name": "Mesures contre les codes malveillants", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "artifacts", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures de detection, de prevention et de restauration pour se proteger des codes malveillants. Le perimetre d'application de cette exigence sur le systeme d'information du service doit necessairement contenir les postes utilisateurs sous la responsabilite du prestataire et les flux entrants sur ce meme systeme d'information. b) Le prestataire doit documenter et mettre en oeuvre une sensibilisation de ses employes aux risques lies aux codes malveillants et aux bonnes pratiques pour reduire l'impact d'une infection." + } + ], + "Checks": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled", + "compute_instance_shielded_vm_enabled" + ] + }, + { + "Id": "12.5", + "Description": "Des copies de sauvegarde des informations, des logiciels et des images systeme doivent etre effectuees et testees regulierement conformement a une politique de sauvegarde convenue.", + "Name": "Sauvegarde des informations", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "cloudsql", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de sauvegarde et de restauration des donnees sous sa responsabilite dans le cadre du service. Cette politique doit prevoir une sauvegarde quotidienne de l'ensemble des donnees (informations, logiciels, configurations, etc.) sous la responsabilite du prestataire dans le cadre du service. b) Le prestataire doit documenter et mettre en oeuvre des mesures de protection des sauvegardes conformement a la politique de controle d'acces (voir chapitre 9). Cette politique doit prevoir une revue mensuelle des traces d'acces aux sauvegardes. c) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester regulierement la restauration des sauvegardes. d) Le prestataire doit localiser les sauvegardes a une distance suffisante des equipements principaux en coherence avec les resultats de l'appreciation de risques et permettant de faire face a des sinistres majeurs. Les sauvegardes sont assujetties aux memes exigences de localisation que les donnees operationnelles. Le ou les sites de sauvegarde sont assujettis aux memes exigences de securite que le site principal, en particulier celles listees aux chapitres 8 et 11. Les communications entre site principal et site de sauvegarde doivent etre protegees par chiffrement, conformement aux exigences du chapitre 10." + } + ], + "Checks": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_versioning_enabled", + "cloudstorage_bucket_soft_delete_enabled" + ] + }, + { + "Id": "12.6", + "Description": "Des journaux d'evenements enregistrant les activites des utilisateurs, les exceptions, les defaillances et les evenements de securite de l'information doivent etre crees, tenus a jour et regulierement revus.", + "Name": "Journalisation des evenements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de journalisation incluant au minimum les elements suivants : la liste des sources de collecte ; la liste des evenements a journaliser par source ; l'objet de la journalisation par evenement ; la frequence de la collecte et base de temps utilisee ; la duree de retention locale et centralisee ; les mesures de protection des journaux (dont chiffrement et duplication) ; la localisation des journaux. b) Le prestataire doit generer et collecter les evenements suivants : les activites des utilisateurs liees a la securite de l'information ; la modification des droits d'acces dans le perimetre de sa responsabilite ; les evenements issus des mecanismes de lutte contre les codes malveillants (voir chapitre 12.4) ; les exceptions ; les defaillances ; tout autre evenement lie a la securite de l'information. c) Le prestataire doit conserver les evenements issus de la journalisation pendant une duree minimale de six mois sous reserve du respect des exigences legales et reglementaires. d) Le prestataire doit fournir, sur demande d'un commanditaire, l'ensemble des evenements le concernant. e) Il est recommande que le systeme de journalisation mis en place par le prestataire respecte les recommandations de [NT_JOURNAL]." + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "cloudstorage_audit_logs_enabled", + "logging_sink_created", + "cloudstorage_bucket_sufficient_retention_period", + "compute_subnet_flow_logs_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "cloudsql_instance_postgres_enable_pgaudit_flag", + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag" + ] + }, + { + "Id": "12.7", + "Description": "Les moyens de journalisation et les informations journalisees doivent etre proteges contre les risques de falsification et les acces non autorises.", + "Name": "Protection de l'information journalisee", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "cloudstorage", + "Type": "Automated", + "Comment": "a) Le prestataire doit proteger les equipements de journalisation et les evenements journalises contre les atteintes a leur disponibilite, integrite ou confidentialite, conformement au chapitre 3.2 de [NT_JOURNAL]. b) Le prestataire doit gerer le dimensionnement de l'espace de stockage de l'ensemble des equipements hebergeant une ou plusieurs sources de collecte afin de permettre la conservation locale des evenements journalises prevue par la politique de journalisation des evenements. Cette gestion du dimensionnement doit prendre en compte les evolutions du systeme d'information. c) Le prestataire doit transferer les evenements journalises en assurant leur protection en confidentialite et en integrite, sur un ou plusieurs serveurs centraux dedies et doit les stocker sur une machine physique distincte de celle qui les a generes. d) Le prestataire doit mettre en place une sauvegarde des evenements collectes suivant une politique adaptee. e) Le prestataire doit executer les processus de journalisation et de collecte des evenements avec des comptes disposant de privileges necessaires et suffisants et doit limiter l'acces aux evenements journalises conformement a la politique de controle d'acces (voir chapitre 9.1)." + } + ], + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_logging_enabled", + "cloudstorage_bucket_uniform_bucket_level_access" + ] + }, + { + "Id": "12.8", + "Description": "Les horloges de tous les systemes de traitement de l'information pertinents d'un organisme ou d'un domaine de securite doivent etre synchronisees sur une source de reference temporelle unique.", + "Name": "Synchronisation des horloges", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une synchronisation des horloges de l'ensemble des equipements sur une ou plusieurs sources de temps internes coherentes entre elles. Ces sources pourront elles-memes etre synchronisees sur plusieurs sources fiables externes, sauf pour les reseaux isoles. b) Le prestataire doit mettre en place l'horodatage de chaque evenement journalise." + } + ], + "Checks": [] + }, + { + "Id": "12.9", + "Description": "Les evenements de securite doivent etre analyses et correles afin de detecter les incidents de securite. Des systemes de detection et de correlation doivent etre mis en oeuvre.", + "Name": "Analyse et correlation des evenements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "logging", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une infrastructure permettant l'analyse et la correlation des evenements enregistres par le systeme de journalisation afin de detecter les evenements susceptibles d'affecter la securite du systeme d'information du service, en temps reel ou a posteriori pour des evenements remontant jusqu'a six mois. b) Il est recommande de s'appuyer sur le referentiel d'exigences des prestataires de detection d'incidents de securite [PDIS] pour la mise en place et l'exploitation de l'infrastructure d'analyse et de correlation des evenements. c) Le prestataire doit acquitter les alarmes remontees par l'infrastructure d'analyse et de correlation des evenements au moins quotidiennement." + } + ], + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled" + ] + }, + { + "Id": "12.10", + "Description": "Des regles regissant l'installation de logiciels par les utilisateurs doivent etre etablies et mises en oeuvre. Les systemes doivent etre geres de maniere centralisee et les correctifs appliques regulierement.", + "Name": "Installation de logiciels sur des systemes en exploitation", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "ssm", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de controler l'installation de logiciels sur les equipements du systeme d'information du service. b) Le prestataire doit documenter et mettre en oeuvre une procedure de gestion de la configuration des environnements logiciels mis a la disposition du commanditaire, notamment pour leur maintien en condition de securite. c) Le prestataire doit fournir une capacite d'inspection et de suppression, si necessaire, des entrants (controle de l'authenticite et de l'innocuite des mises a jour, controle de l'innocuite des outils fournis, etc.) relatifs au perimetre de l'infrastructure technique : cette capacite d'inspection et de suppression doit generer des journaux d'activite et doit pouvoir faire l'objet d'un audit de code ; les entrants doivent etre traites sur des dispositifs specifiques operes et maintenus par le prestataire et heberges dans une zone cloisonnee du reste de l'infrastructure (du type zone demilitarisee telle que definie dans [G_INT])." + } + ], + "Checks": [] + }, + { + "Id": "12.11", + "Description": "Les informations sur les vulnerabilites techniques des systemes d'information utilises doivent etre obtenues en temps voulu, l'exposition du prestataire a ces vulnerabilites doit etre evaluee et les mesures appropriees doivent etre prises pour traiter le risque associe.", + "Name": "Gestion des vulnerabilites techniques", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "artifacts", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus de veille permettant de gerer les vulnerabilites techniques des logiciels et des systemes utilises dans le systeme d'information du service. b) Le prestataire doit evaluer son exposition a ces vulnerabilites en les incluant dans l'appreciation des risques et appliquer les mesures de traitement du risque adaptees." + } + ], + "Checks": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ] + }, + { + "Id": "12.12", + "Description": "L'administration des systemes d'information du service cloud doit etre effectuee de maniere securisee via des canaux dedies et des protocoles securises.", + "Name": "Administration", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "compute", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure obligeant les administrateurs sous sa responsabilite a utiliser des terminaux dedies pour la realisation exclusive des taches d'administration, en accord avec le chapitre 4.1 intitule 'poste et reseau d'administration' de [NT_ADMIN]. Il doit les maitriser et les maintenir a jour. b) Le prestataire doit mettre en place des mesures de durcissement de la configuration des terminaux utilises pour les taches d'administration, notamment celles du chapitre 4.2 intitule 'securisation du socle' de [NT_ADMIN]. c) Lorsque le prestataire autorise une situation de mobilite pour les administrateurs sous sa responsabilite, il doit l'encadrer par une politique documentee. La solution mise en oeuvre doit assurer que le niveau de securite de cette situation de mobilite est au moins equivalent au niveau de securite hors situation de mobilite (voir chapitres 9.6 et 9.7). Cette solution doit notamment inclure : l'utilisation d'un tunnel chiffre, non debrayable et non contournable, pour l'ensemble des flux (voir chapitre 10.2) ; le chiffrement integral du disque (voir chapitre 10.1)." + } + ], + "Checks": [ + "compute_project_os_login_enabled", + "compute_project_os_login_2fa_enabled", + "compute_instance_serial_ports_in_use" + ] + }, + { + "Id": "12.13", + "Description": "Le telediagnostic et la telemaintenance des composants de l'infrastructure doivent etre encadres par des procedures de securite specifiques.", + "Name": "Telediagnostic et telemaintenance des composants de l'infrastructure", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Dans le cadre du telediagnostic ou de la telemaintenance de composants de l'infrastructure, considerant les risques d'atteinte a la confidentialite des donnees des commanditaires, le prestataire doit : verifier que la personne a qui l'acces doit etre autorise a satisfait aux verifications de l'exigence 7.1.b ; dans le cas d'une intervention realisee par une personne n'ayant pas satisfait aux verifications de l'exigence 7.1.b, mettre en oeuvre une passerelle securisee (poste de rebond) par laquelle la personne devra se connecter et permettant une supervision des actions (autorisation ou interdiction des actions, demande d'explications, etc.) en temps reel, par une personne ayant elle-meme satisfait aux verifications de l'exigence 7.1.b. La passerelle securisee devra repondre aux objectifs de securite specifies dans [G_EXT] ; considerer les actions menees, une fois l'acces autorise, comme des actions d'administration et les journaliser comme telles ; supprimer l'autorisation d'acces a l'issue de l'intervention." + } + ], + "Checks": [] + }, + { + "Id": "12.14", + "Description": "Les flux sortants de l'infrastructure du service cloud doivent etre surveilles afin de detecter et de prevenir les exfiltrations de donnees et les communications non autorisees.", + "Name": "Surveillance des flux sortants de l'infrastructure", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "compute", + "Type": "Automated", + "Comment": "a) Le prestataire doit fournir une capacite d'inspection et de suppression des sortants de l'infrastructure technique relatifs au perimetre du service (informations de facturation, les eventuels journaux necessaires au traitement d'incidents, etc.) : les sortants doivent pouvoir etre expurges des donnees pouvant porter atteinte a la confidentialite des donnees des commanditaires ; cette capacite d'inspection et de suppression doit generer des journaux d'activite et doit pouvoir faire l'objet d'un audit de code ; les sortants sont traites sur des dispositifs specifiques operes et maintenus par le prestataire, et heberges dans une zone cloisonnee du reste de l'infrastructure (du type zone demilitarisee telle que definie dans [G_INT])." + } + ], + "Checks": [ + "compute_subnet_flow_logs_enabled" + ] + }, + { + "Id": "13.1", + "Description": "Le prestataire doit etablir et maintenir une cartographie complete et a jour de son systeme d'information, incluant les reseaux, les flux et les composants.", + "Name": "Cartographie du systeme d'information", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "iam", + "Type": "Automated", + "Comment": "a) Le prestataire doit etablir et tenir a jour une cartographie du systeme d'information du service, en lien avec l'inventaire des actifs (voir chapitre 8.1), comprenant au minimum les elements suivants : la liste des ressources materielles ou virtualisees ; les noms et fonctions des applications, supportant le service ; le schema d'architecture reseau au niveau 3 du modele OSI sur lequel les points nevralgiques sont identifies : les points d'interconnexions, notamment avec les reseaux tiers et publics ; les reseaux, sous-reseaux, notamment les reseaux d'administration ; les equipements assurant des fonctions de securite (filtrage, authentification, chiffrement, etc.) ; les serveurs hebergeant des donnees ou assurant des fonctions sensibles ; la matrice des flux reseau autorises en precisant : leur description technique (services, protocoles et ports) ; la justification metier ou d'infrastructure technique ; le cas echeant, lorsque des services, protocoles ou ports reputes non surs sont utilises, les mesures compensatoires mises en place, dans la logique de defense en profondeur. b) Le prestataire doit reviser au moins annuellement la cartographie." + } + ], + "Checks": [ + "iam_cloud_asset_inventory_enabled", + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled" + ] + }, + { + "Id": "13.2", + "Description": "Les reseaux doivent etre cloisonnes et les flux entre les segments doivent etre filtres selon le principe du moindre privilege. Les groupes de securite et les listes de controle d'acces reseau doivent etre configures de maniere restrictive.", + "Name": "Cloisonnement des reseaux", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "compute", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre, pour le systeme d'information du service, les mesures de cloisonnement (logique, physique ou par chiffrement) pour separer les flux reseau selon : la sensibilite des informations transmises ; la nature des flux (production, administration, supervision, etc.) ; le domaine d'appartenance des flux (des commanditaires - avec distinction par commanditaire ou ensemble de commanditaires, du prestataire, des tiers, etc.) ; le domaine technique (traitement, stockage, etc.). b) Le prestataire doit cloisonner, physiquement ou par chiffrement, tous les flux de donnees internes au systeme d'information du service vis-a-vis de tout autre systeme d'information. Lorsque ce cloisonnement est realise par chiffrement, il est realise en accord avec les exigences du chapitre 10.2. c) Dans le cas ou le reseau d'administration de l'infrastructure technique ne fait pas l'objet d'un cloisonnement physique, les flux d'administration doivent transiter dans un tunnel chiffre, en accord avec les exigences du chapitre 10.2. d) Le prestataire doit mettre en place et configurer un pare-feu applicatif pour proteger les interfaces d'administration destinees a ses commanditaires et exposees sur un reseau public. e) Le prestataire doit mettre en oeuvre sur l'ensemble des interfaces d'administration et de supervision de l'infrastructure technique du service un mecanisme de filtrage n'autorisant que les connexions legitimes identifiees dans la matrice des flux autorises." + } + ], + "Checks": [ + "compute_network_default_in_use", + "compute_network_not_legacy", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "cloudstorage_uses_vpc_service_controls", + "compute_instance_single_network_interface", + "compute_instance_ip_forwarding_is_enabled" + ] + }, + { + "Id": "13.3", + "Description": "Les reseaux doivent etre surveilles de maniere continue afin de detecter les activites anormales ou malveillantes.", + "Name": "Surveillance des reseaux", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "compute", + "Type": "Automated", + "Comment": "a) Le prestataire doit disposer une ou plusieurs sondes de detection d'incidents de securite sur le systeme d'information du service. Ces sondes doivent notamment permettre la supervision de chacune des interconnexions du systeme d'information du service avec des systemes d'information tiers et des reseaux publics. Ces sondes doivent etre des sources de collecte pour l'infrastructure d'analyse et de correlation des evenements (voir chapitre 12.9)." + } + ], + "Checks": [ + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled" + ] + }, + { + "Id": "14.1", + "Description": "Des regles de developpement securise des logiciels et des systemes doivent etre etablies et appliquees au sein du prestataire.", + "Name": "Politique de developpement securise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des regles de developpement securise des logiciels et des systemes, et les appliquer aux developpements internes. b) Le prestataire doit documenter et mettre en oeuvre une formation adaptee en developpement securise aux employes concernes." + } + ], + "Checks": [] + }, + { + "Id": "14.2", + "Description": "Les changements apportes aux systemes dans le cycle de developpement doivent etre geres a l'aide de procedures formelles de controle des changements.", + "Name": "Procedures de controle des changements de systeme", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "iam", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de controle des changements apportes au systeme d'information du service. b) Le prestataire doit documenter et mettre en oeuvre une procedure de validation des changements apportes au systeme d'information du service sur un environnement de pre-production avant leur mise en production. c) Le prestataire doit conserver un historique des versions des logiciels et des systemes (developpements internes ou externes, produits commerciaux) mis en oeuvre pour permettre de reconstituer, le cas echeant dans un environnement de test, un environnement complet tel qu'il etait mis en oeuvre a une date donnee. La duree de conservation de cet historique doit etre en accord avec celle des sauvegardes (voir chapitre 12.5)." + } + ], + "Checks": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled" + ] + }, + { + "Id": "14.3", + "Description": "Lorsque les plateformes d'exploitation sont modifiees, les applications critiques metier doivent etre revues et testees afin de verifier qu'il n'y a pas d'effet indesirable sur l'activite ou la securite du prestataire.", + "Name": "Revue technique des applications apres changement apporte a la plateforme d'exploitation", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester, prealablement a leur mise en production, l'ensemble des applications afin de verifier l'absence de tout effet indesirable sur l'activite ou sur la securite du service." + } + ], + "Checks": [] + }, + { + "Id": "14.4", + "Description": "Les environnements de developpement doivent etre securises et isoles des environnements de production.", + "Name": "Environnement de developpement securise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "organizations", + "Type": "Manual", + "Comment": "a) Le prestataire doit mettre en oeuvre un environnement securise de developpement permettant de gerer l'integralite du cycle de developpement du systeme d'information du service. b) Le prestataire doit prendre en compte les environnements de developpement dans l'appreciation des risques et en assurer la protection conformement au present referentiel." + } + ], + "Checks": [] + }, + { + "Id": "14.5", + "Description": "Le prestataire doit superviser et surveiller l'activite de developpement externalise du systeme.", + "Name": "Developpement externalise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de superviser et de controler l'activite de developpement externalise des logiciels et des systemes. Cette procedure doit s'assurer que l'activite de developpement externalise soit conforme a la politique de developpement securise du prestataire et permette d'atteindre un niveau de securite du developpement externe equivalent a celui d'un developpement interne (voir exigence 14.1 a))." + } + ], + "Checks": [] + }, + { + "Id": "14.6", + "Description": "Des tests de securite et de conformite doivent etre effectues tout au long du cycle de developpement et apres chaque changement significatif.", + "Name": "Test de la securite et conformite du systeme", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "artifacts", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit soumettre les systemes d'information, nouveaux ou mis a jour, a des tests de conformite et de fonctionnalite de securite pendant le developpement. Il doit documenter et mettre en oeuvre une procedure de test qui identifie : les taches a realiser ; les donnees d'entree ; les resultats attendus en sortie." + } + ], + "Checks": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ] + }, + { + "Id": "14.7", + "Description": "Les donnees de test doivent etre soigneusement selectionnees, protegees et controlees.", + "Name": "Protection des donnees de test", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'assurer l'integrite des donnees de tests utilises en pre-production. b) Si le prestataire souhaite utiliser des donnees du commanditaire issues de la production pour realiser des tests, le prestataire doit prealablement obtenir l'accord du commanditaire et les anonymiser. Le prestataire doit assurer la confidentialite des donnees lors de leur anonymisation." + } + ], + "Checks": [] + }, + { + "Id": "15.1", + "Description": "Le prestataire doit identifier les tiers ayant acces a l'information ou aux moyens de traitement de l'information et evaluer les risques associes.", + "Name": "Identification des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit tenir a jour une liste exhaustive des tiers participant a la mise en oeuvre du service (hebergeur, developpeur, integrateur, archiveur, sous-traitant operant sur site ou a distance, fournisseurs de climatisation, etc.). Cette liste doit preciser la contribution du tiers au service et au traitement des donnees a caractere personnel. Elle doit tenir compte des cas de sous-traitance a plusieurs niveaux. b) Le prestataire doit tenir a disposition du commanditaire la liste de l'ensemble des tiers qui peuvent acceder aux donnees et l'informer de tout changement de sous-traitants au sens de l'article 28 du [RGPD] afin que le commanditaire puisse emettre des objections a cet egard." + } + ], + "Checks": [] + }, + { + "Id": "15.2", + "Description": "Tous les aspects pertinents de la securite de l'information doivent etre traites dans les accords conclus avec les tiers.", + "Name": "La securite dans les accords conclus avec les tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit exiger des tiers participant a la mise en oeuvre du service, dans leur contribution au service, un niveau de securite au moins equivalent a celui qu'il s'engage a maintenir dans sa propre politique de securite. Il doit le faire au travers d'exigences, adaptees a chaque tiers et a sa contribution au service, dans les cahiers des charges ou dans les clauses de securite des accords de partenariat. Le prestataire doit inclure ces exigences dans les contrats conclus avec les tiers. b) Le prestataire doit contractualiser, avec chacun des tiers participant a la mise en oeuvre du service, des clauses d'audit permettant a un organisme de qualification de verifier que ces tiers respectent les exigences du present referentiel. c) Le prestataire doit definir et attribuer les roles et les responsabilites relatives a la modification ou a la fin du contrat le liant a un tiers participant a la mise en oeuvre du service." + } + ], + "Checks": [] + }, + { + "Id": "15.3", + "Description": "Le prestataire doit surveiller, revoir et auditer a intervalles reguliers la prestation des services des tiers.", + "Name": "Surveillance et revue des services des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de controler regulierement les mesures mises en place par les tiers participant a la mise en oeuvre du service pour respecter les exigences du present referentiel, conformement au chapitre 18.3." + } + ], + "Checks": [] + }, + { + "Id": "15.4", + "Description": "Les changements dans les services des tiers, incluant le maintien et l'amelioration des politiques, procedures et mesures existantes de securite de l'information, doivent etre geres.", + "Name": "Gestion des changements apportes dans les services des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de suivi des changements apportes par les tiers participant a la mise en oeuvre du service susceptibles d'affecter le niveau de securite du systeme d'information du service. b) Dans la mesure ou un changement de tiers participant a la mise en oeuvre du service affecte le niveau de securite du service, le prestataire doit en informer l'ensemble des commanditaires sans delais conformement au chapitre 12.2 et mettre en oeuvre les mesures permettant de retablir le niveau de securite precedent." + } + ], + "Checks": [] + }, + { + "Id": "15.5", + "Description": "Les personnes intervenant dans le cadre du service cloud doivent etre soumises a des engagements de confidentialite.", + "Name": "Engagements de confidentialite", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de reviser au moins annuellement les exigences en matiere d'engagements de confidentialite ou de non-divulgation vis-a-vis des tiers participant a la mise en oeuvre du service." + } + ], + "Checks": [] + }, + { + "Id": "16.1", + "Description": "Des responsabilites et des procedures de gestion doivent etre etablies pour garantir une reponse rapide, efficace et ordonnee aux incidents lies a la securite de l'information.", + "Name": "Responsabilites et procedures", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'apporter des reponses rapides et efficaces aux incidents de securite. Ces procedures doivent definir les moyens et delais de communication des incidents de securite a l'ensemble des commanditaires concernes ainsi que le niveau de confidentialite exige pour cette communication. b) Le prestataire doit informer ses employes et l'ensemble des tiers participant a la mise en oeuvre du service de cette procedure. c) Le prestataire doit documenter toute violation de donnees a caractere personnel et en informer son commanditaire. La violation doit etre notifiee a la CNIL si elle presente un risque pour les droits et libertes des personnes concernees. Elle doit faire l'objet d'une information aupres des personnes concernees lorsque le risque pour leur vie privee est eleve." + } + ], + "Checks": [] + }, + { + "Id": "16.2", + "Description": "Les evenements lies a la securite de l'information doivent etre signales dans les meilleurs delais par les voies hierarchiques appropriees. Des mecanismes de detection et de notification automatises doivent etre mis en oeuvre.", + "Name": "Signalements lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "iam", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure exigeant de ses employes et des tiers participant a la mise en oeuvre du service qu'ils lui rendent compte de tout incident de securite, avere ou suspecte ainsi que de toute faille de securite. b) Le prestataire doit documenter et mettre en oeuvre une procedure permettant a l'ensemble des commanditaires de signaler tout incident de securite, avere ou suspecte et toute faille de securite. c) Le prestataire doit communiquer sans delai aux commanditaires les incidents de securite et les preconisations associees pour en limiter les impacts. Il doit permettre au commanditaire de choisir les niveaux de gravite des incidents pour lesquels il souhaite etre informe. d) Le prestataire doit communiquer les incidents de securite aux autorites competentes conformement aux exigences legales et reglementaires en vigueur." + } + ], + "Checks": [ + "iam_organization_essential_contacts_configured" + ] + }, + { + "Id": "16.3", + "Description": "Les evenements lies a la securite de l'information doivent etre apprecies et il doit etre decide s'il est necessaire de les classer comme incidents lies a la securite de l'information.", + "Name": "Appreciation des evenements et prise de decision", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "guardduty", + "Type": "Manual", + "Comment": "a) Le prestataire doit apprecier les evenements lies a la securite de l'information et decider s'il faut les qualifier en incidents de securite. Pour l'appreciation, il doit s'appuyer sur une ou plusieurs echelles (estimation, evaluation, etc.) partagees avec le commanditaire. Note : Les incidents de securite incluent les violations de donnees a caractere personnel. b) Le prestataire doit utiliser une classification permettant d'identifier clairement les incidents de securite touchant des donnees relatives aux commanditaires, conformement aux resultats de l'appreciation des risques. Cette classification doit inclure les violations de donnees a caractere personnel." + } + ], + "Checks": [] + }, + { + "Id": "16.4", + "Description": "Les incidents lies a la securite de l'information doivent etre traites conformement aux procedures documentees.", + "Name": "Reponse aux incidents lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit traiter les incidents de securite jusqu'a leur resolution et doit informer les commanditaires conformement aux procedures." + } + ], + "Checks": [] + }, + { + "Id": "16.5", + "Description": "Les connaissances acquises lors de l'analyse et du traitement des incidents lies a la securite de l'information doivent etre exploitees pour reduire la probabilite ou l'impact d'incidents futurs.", + "Name": "Tirer des enseignements des incidents lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus d'amelioration continue afin de diminuer l'occurrence et l'impact de types d'incidents de securite deja traites." + } + ], + "Checks": [] + }, + { + "Id": "16.6", + "Description": "Le prestataire doit definir et appliquer des procedures pour l'identification, le recueil, l'acquisition et la preservation de preuves. Les journaux d'audit doivent etre proteges et valides.", + "Name": "Recueil de preuves", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "iam", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'enregistrer les informations relatives aux incidents de securite et pouvant servir d'elements de preuve." + } + ], + "Checks": [ + "iam_audit_logs_enabled", + "cloudstorage_bucket_log_retention_policy_lock" + ] + }, + { + "Id": "17.1", + "Description": "Le prestataire doit determiner ses exigences en matiere de securite de l'information et de continuite du management de la securite de l'information dans des situations defavorables, par exemple lors d'une crise ou d'un sinistre.", + "Name": "Organisation de la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre oeuvre un plan de continuite d'activite prenant en compte la securite de l'information. b) Le prestataire doit reviser annuellement le plan de continuite d'activite du service et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "17.2", + "Description": "Le prestataire doit etablir, documenter, mettre en oeuvre et maintenir des processus, des procedures et des mesures de controle pour assurer le niveau requis de continuite de la securite de l'information au cours d'une situation defavorable. Les services doivent etre deployes en multi-AZ.", + "Name": "Mise en oeuvre de la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "compute", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des procedures permettant de maintenir ou de restaurer l'exploitation du service et d'assurer la disponibilite des informations au niveau et dans les delais pour lesquels le prestataire s'est engage vis-a-vis du commanditaire dans la convention de service." + } + ], + "Checks": [ + "compute_instance_group_multiple_zones", + "compute_instance_group_autohealing_enabled", + "compute_instance_automatic_restart_enabled", + "compute_instance_on_host_maintenance_migrate" + ] + }, + { + "Id": "17.3", + "Description": "Le prestataire doit verifier a intervalles reguliers les mesures de continuite de la securite de l'information mises en oeuvre afin de s'assurer qu'elles sont valables et efficaces dans des situations defavorables.", + "Name": "Verifier, revoir et evaluer la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester le plan de continuite d'activites afin de s'assurer qu'il est pertinent et efficace en situation de crise." + } + ], + "Checks": [] + }, + { + "Id": "17.4", + "Description": "Les moyens de traitement de l'information doivent etre mis en oeuvre avec suffisamment de redondance pour repondre aux exigences de disponibilite. Les mecanismes de protection contre la suppression accidentelle doivent etre actives.", + "Name": "Disponibilite des moyens de traitement de l'information", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "compute", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures qui lui permettent de repondre au besoin de disponibilite du service defini dans la convention de service (voir chapitre 19.1)." + } + ], + "Checks": [ + "compute_instance_group_multiple_zones", + "compute_instance_deletion_protection_enabled", + "compute_instance_group_autohealing_enabled", + "compute_instance_disk_auto_delete_disabled" + ] + }, + { + "Id": "17.5", + "Description": "La configuration de l'infrastructure technique du service cloud doit etre sauvegardee regulierement afin de permettre sa restauration en cas de sinistre.", + "Name": "Sauvegarde de la configuration de l'infrastructure technique", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "cloudsql", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de sauvegarde hors-ligne de la configuration de l'infrastructure technique." + } + ], + "Checks": [ + "cloudsql_instance_automated_backups", + "iam_cloud_asset_inventory_enabled" + ] + }, + { + "Id": "17.6", + "Description": "Le prestataire doit mettre a disposition du commanditaire un dispositif de sauvegarde de ses donnees, permettant la restauration en cas de sinistre.", + "Name": "Mise a disposition d'un dispositif de sauvegarde des donnees du commanditaire", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "cloudsql", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre a disposition du commanditaire un service de sauvegarde de ses donnees." + } + ], + "Checks": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_versioning_enabled", + "cloudstorage_bucket_soft_delete_enabled" + ] + }, + { + "Id": "18.1", + "Description": "Toutes les exigences legales, reglementaires et contractuelles en vigueur, ainsi que l'approche du prestataire pour satisfaire ces exigences, doivent etre explicitement definies, documentees et tenues a jour pour chaque systeme d'information et pour le prestataire.", + "Name": "Identification de la legislation et des exigences contractuelles applicables", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les exigences legales, reglementaires et contractuelles en vigueur applicables au service. En France, le prestataire doit considerer au minimum les textes suivants : les donnees a caractere personnel [LOI_IL], [RGPD] ; le secret professionnel [CP_ART_226_13], le cas echeant sans prejudice de l'application de l'article 40 alinea 2 du Code de procedure penale relatif au signalement a une autorite judiciaire ; l'abus de confiance [CP_ART_314-1] ; le secret des correspondances privees [CP_ART_226-15] ; l'atteinte a la vie privee [CP_ART_226-1] ; l'acces ou le maintien frauduleux a un systeme d'information [CP_ART_323-1]. b) Le prestataire doit, selon son role dans les traitements de donnees a caractere personnel (responsable de traitement, sous-traitant ou co-responsable) justifier et documenter les choix de mesures techniques et organisationnelles realises en vue de repondre aux exigences de protection des donnees a caractere personnel du present referentiel (voir partie 19.5). c) Le prestataire doit documenter et mettre en oeuvre les procedures permettant de respecter les exigences legales, reglementaires et contractuelles en vigueur applicables au service, ainsi que les besoins de securite specifiques (voir exigence 8.3b)). d) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible l'ensemble de ces procedures. e) Le prestataire doit documenter et mettre en oeuvre un processus de veille actif des exigences legales, reglementaires et contractuelles en vigueur applicables au service." + } + ], + "Checks": [] + }, + { + "Id": "18.2", + "Description": "L'approche du prestataire vis-a-vis de la gestion de la securite de l'information et sa mise en oeuvre (c'est-a-dire les objectifs de controle, les mesures, les politiques, les procedures et les processus relatifs a la securite de l'information) doivent etre revues de maniere independante a intervalles definis ou en cas de changement significatif.", + "Name": "Revue independante de la securite de l'information", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un programme d'audit sur trois ans definissant le perimetre et la frequence des audits en accord avec la gestion du changement, les politiques, et les resultats de l'appreciation des risques. Le prestataire doit inclure dans le programme d'audit un audit qualifie par an realise par un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie. L'ensemble du programme d'audit doit notamment couvrir : l'audit de la configuration de l'infrastructure technique du service (par echantillonnage et doit inclure tous types d'equipements et de serveurs presents dans le systeme d'information du service) ; le test d'intrusion des interfaces d'administration exposees sur un reseau public ; le test d'intrusion de l'interface utilisateur pour les services SaaS ; si le service beneficie de developpements internes, l'audit de code source portant sur les fonctionnalites de securite implementees (l'approche en continue doit etre privilegiee). b) Il est recommande que le prestataire mette en oeuvre des mecanismes automatises d'audit de la configuration adaptes a l'infrastructure technique du service." + } + ], + "Checks": [] + }, + { + "Id": "18.3", + "Description": "Les responsables doivent regulierement s'assurer de la conformite du traitement de l'information et des procedures au sein de leur domaine de responsabilite, au regard des politiques et des normes de securite.", + "Name": "Conformite avec les politiques et les normes de securite", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "iam", + "Type": "Partially Automated", + "Comment": "a) Le prestataire via le responsable de la securite de l'information doit s'assurer regulierement de l'execution correcte de l'ensemble des procedures de securite placees sous sa responsabilite en vue de garantir leur conformite avec les politiques et normes de securite." + } + ], + "Checks": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled" + ] + }, + { + "Id": "18.4", + "Description": "Les systemes d'information doivent etre examines regulierement quant a leur conformite avec les politiques et les normes de securite de l'information du prestataire.", + "Name": "Examen de la conformite technique", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "artifacts", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique permettant de verifier la conformite technique du service aux exigences du present referentiel. Cette politique doit definir les objectifs, methodes, frequences, resultats attendus et mesures correctrices." + } + ], + "Checks": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ] + }, + { + "Id": "19.1", + "Description": "Le prestataire doit etablir une convention de service avec le commanditaire definissant les engagements de niveau de service, les responsabilites et les conditions d'utilisation du service cloud.", + "Name": "Convention de service", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit etablir une convention de service avec chacun des commanditaires du service. Toute modification de la convention de service doit etre soumise a acceptation du commanditaire. b) Le prestataire doit identifier dans la convention de service : les obligations, droits et responsabilites de chacune des parties : prestataire et tiers impliques dans la fourniture du service, commanditaires, etc. ; les elements explicitement exclus des responsabilites du prestataire dans la limite de ce que prevoient les exigences legales et reglementaires en vigueur, notamment l'article 28 du [RGPD] ; la localisation du service. La localisation du support doit etre precisee lorsqu'il est realise depuis un Etat hors l'Union Europeenne, comme le permet l'exigence 19.2.e. c) Le prestataire doit proposer une convention de service appliquant le droit d'un Etat membre de l'Union Europeenne. Le droit applicable doit etre identifie dans la convention de service. d) La convention de service doit indiquer que la collecte, la manipulation, le stockage, et plus generalement le traitement des donnees faits dans le cadre de l'avant-vente, de la mise en oeuvre, de la maintenance et l'arret du service sont realises conformement aux exigences edictees par la legislation en vigueur. e) La convention de service doit indiquer que le prestataire doit mettre a la disposition du commanditaire, sur demande de celui-ci, les elements d'appreciation des risques lies a la soumission des donnees du commanditaire au droit d'un etat non-membre de l'Union Europeenne (voir 5.3.e). f) Le prestataire doit decrire dans la convention de service les moyens techniques et organisationnels qu'il met en oeuvre pour assurer le respect du droit applicable. g) Le prestataire doit inclure dans la convention de service une clause de revision de la convention prevoyant notamment une resiliation sans penalite pour le commanditaire en cas de perte de la qualification octroyee au service. h) Le prestataire doit inclure dans la convention de service une clause de reversibilite permettant au commanditaire de recuperer l'ensemble de ses donnees (fournies directement par le commanditaire ou produites dans le cadre du service a partir des donnees ou des actions du commanditaire). i) Le prestataire doit assurer cette reversibilite via l'une des modalites techniques suivantes : la mise a disposition de fichiers suivant un ou plusieurs formats documentes et exploitables en dehors du service fourni par le prestataire ; la mise en place d'interfaces techniques permettant l'acces aux donnees suivant un schema documente et exploitable (API, format pivot, etc.). Les modalites techniques de la reversibilite figurent dans la convention de service. j) Le prestataire doit indiquer dans la convention de service le niveau de disponibilite du service. k) Le prestataire doit indiquer dans la convention de service qu'il ne peut disposer des donnees transmises et generees par le commanditaire, leur disposition etant reservee au commanditaire. l) Le prestataire doit indiquer dans la convention de service qu'il ne divulgue aucune information relative a la prestation a des tiers, sauf autorisation formelle et ecrite du commanditaire. m) Le prestataire doit indiquer dans la convention de service si les donnees du commanditaire sont automatiquement sauvegardees ou non. Dans la negative, le prestataire doit sensibiliser le commanditaire aux risques encourus et clairement indiquer les operations a mener par le commanditaire pour que ses donnees soient sauvegardees. n) Le prestataire doit indiquer dans la convention de service s'il autorise l'acces distant pour des actions d'administration ou de support au systeme d'information du service. o) Le prestataire doit preciser dans la convention de service que : le service est qualifie et inclure l'attestation de qualification ; le commanditaire peut deposer une reclamation relative au service qualifie aupres de l'ANSSI ; le commanditaire autorise l'ANSSI et l'organisme de qualification a auditer le service et son systeme d'information du service afin de verifier qu'ils respectent les exigences du present referentiel. p) Le prestataire doit preciser dans la convention de service que le commanditaire autorise, conformement au present referentiel (voir chapitre 18.2, un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie mandate par le prestataire a auditer le service et son systeme d'information dans le cadre du plan de controle. q) Le prestataire doit preciser dans la convention de service qu'il s'engage a mettre a disposition toutes les informations necessaires a la realisation d'audits de conformite aux dispositions de l'article 28 du [RGPD], menes par le commanditaire ou un tiers mandate. r) Il est recommande que le tiers mandate pour les audits soit un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie." + } + ], + "Checks": [] + }, + { + "Id": "19.2", + "Description": "Les donnees du commanditaire doivent etre stockees et traitees dans des centres de donnees situes sur le territoire de l'Union europeenne. Les politiques de restriction de region doivent etre appliquees.", + "Name": "Localisation des donnees", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "organizations", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et communiquer au commanditaire la localisation du stockage et du traitement des donnees de ce dernier. b) Le prestataire doit stocker et traiter les donnees du commanditaire au sein de l'Union Europeenne. c) Les operations d'administration et de supervision du service doivent etre realisees depuis le territoire de l'Union Europeenne. d) Le prestataire doit stocker et traiter les donnees techniques (identites des beneficiaires et des administrateurs de l'infrastructure technique, donnees manipulees par le Software Defined Network, journaux de l'infrastructure technique, annuaire, certificats, configuration des acces, etc.) au sein de l'Union Europeenne. e) Le prestataire peut realiser des operations de support aux commanditaires depuis un Etat hors de l'Union Europeenne. Il doit documenter la liste des operations qui peuvent etre effectuees par le support au commanditaire depuis un Etat hors de l'Union Europeenne, et les mecanismes permettant d'en assurer le controle d'acces et la supervision depuis l'Union Europeenne." + } + ], + "Checks": [] + }, + { + "Id": "19.3", + "Description": "Les services cloud qualifies SecNumCloud doivent etre operes depuis le territoire de l'Union europeenne.", + "Name": "Regionalisation", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit s'assurer que les interfaces du service accessibles au commanditaire soient au moins disponibles en langue francaise. b) Le prestataire doit fournir un support de premier niveau en langue francaise." + } + ], + "Checks": [] + }, + { + "Id": "19.4", + "Description": "Le prestataire doit definir les conditions de fin de contrat, incluant les modalites de restitution et de suppression des donnees du commanditaire.", + "Name": "Fin de contrat", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) A la fin du contrat liant le prestataire et le commanditaire, que le contrat soit arrive a son terme ou pour toute autre cause, le prestataire doit assurer un effacement securise de l'integralite des donnees du commanditaire. Cet effacement doit faire l'objet d'un preavis formel au commanditaire de la part du prestataire respectant un delai de vingt et un jours calendaires. L'effacement peut etre realise suivant l'une des methodes suivantes, et ce dans un delai precise dans la convention de service : effacement par reecriture complete de tout support ayant heberge ces donnees ; effacement des cles utilisees pour le chiffrement des espaces de stockage du commanditaire decrit au chapitre 10.1 ; recyclage securise, dans les conditions enoncees au chapitre 11.9. b) A la fin du contrat, le prestataire doit supprimer les donnees techniques relatives au commanditaire (annuaire, certificats, configuration des acces, etc.)." + } + ], + "Checks": [] + }, + { + "Id": "19.5", + "Description": "Le prestataire doit mettre en oeuvre des mesures techniques et organisationnelles appropriees pour garantir la protection des donnees a caractere personnel conformement a la reglementation en vigueur.", + "Name": "Protection des donnees a caractere personnel", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit justifier du respect des principes de protection des donnees pour les traitements de donnees a caractere personnel mis en oeuvre pour son propre compte. Il doit justifier au minimum les points suivants : les finalites des traitements determinees, explicites et legitimes ; la tracabilite des activites de traitement pour son compte et celui de son commanditaire ; le fondement licite des traitements ; l'interdiction du detournement de finalite des traitements ; les donnees utilisees respectent le principe du minimum necessaire et suffisant pour les traitements ; ainsi sont adequates, pertinentes et limitees ; la qualite des donnees utilisees pour les traitements maintenue : donnees exactes et tenues a jour ; les durees de conservation definies et limitees. b) Le prestataire doit justifier, pour les traitements de donnees a caractere personnel mis en oeuvre pour son propre compte, du respect des droits des personnes concernees. Il doit justifier au minimum les points suivants : l'information des usagers via un traitement loyal et transparent ; le recueil du consentement des usagers : expres, demontrable et retirable ; la possibilite pour les usagers d'exercer les droits d'acces, de rectification et d'effacement ; la possibilite pour les usagers d'exercer les droits de limitation du traitement, de portabilite et d'opposition. c) Lorsqu'il agit en qualite de sous-traitant au sens de l'article 28 de [RGPD], le prestataire doit apporter assistance et conseil au commanditaire en l'informant si une instruction de ce dernier constitue une violation des regles de protection des donnees." + } + ], + "Checks": [] + }, + { + "Id": "19.6", + "Description": "Le prestataire doit mettre en oeuvre des mesures de protection vis-a-vis du droit extra-europeen, afin de garantir que les donnees du commanditaire ne puissent etre soumises a des legislations extra-europeennes.", + "Name": "Protection vis-a-vis du droit extra-europeen", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le siege statutaire, administration centrale et principal etablissement du prestataire doivent etre etablis au sein d'un Etat membre de l'Union Europeenne. b) Le capital social et les droits de vote dans la societe du prestataire ne doivent pas etre, directement ou indirectement : individuellement detenus a plus de 24% ; et collectivement detenus a plus de 39% ; par des entites tierces possedant leur siege statutaire, administration centrale ou principal etablissement au sein d'un Etat non membre de l'Union europeenne. Ces entites tierces susmentionnees ne peuvent pas individuellement ou collectivement : en vertu d'un contrat ou de clauses statutaires, disposer d'un droit de veto ; en vertu d'un contrat ou de clauses statutaires, designer la majorite des membres des organes d'administration, de direction ou de surveillance du prestataire. c) En cas de recours par le prestataire, dans le cadre des services fournis au commanditaire, aux services d'une societe tierce - y compris un sous-traitant - possedant son siege statutaire, administration centrale ou principal etablissement au sein d'un Etat non membre de l'Union Europeenne ou appartenant ou etant controlee par une societe tierce domiciliee en dehors l'Union Europeenne, cette susdite societe tierce ne doit pas avoir la possibilite technique d'obtenir les donnees operees au travers du service. d) Dans le cadre de l'exigence 19.6.c, toute societe tierce a laquelle le prestataire recourt pour fournir tout ou partie du service rendu au commanditaire, doit garantir au prestataire une autonomie d'exploitation continue dans la fourniture des services d'informatique en nuage qu'il opere ou doit etre qualifie SecNumCloud. e) Le service fourni par le prestataire doit respecter la legislation en vigueur en matiere de droits fondamentaux et les valeurs de l'Union relatives au respect de la dignite humaine, a la liberte, a l'egalite, a la democratie et a l'Etat de droit. f) Le prestataire doit informer formellement le commanditaire, et dans un delai d'un mois, de tout changement juridique, organisationnel ou technique pouvant avoir un impact sur la conformite de la prestation aux exigences du chapitre 19.6." + } + ], + "Checks": [] + } + ] +} diff --git a/prowler/compliance/gcp/soc2_gcp.json b/prowler/compliance/gcp/soc2_gcp.json index 03437ed347..8450967e29 100644 --- a/prowler/compliance/gcp/soc2_gcp.json +++ b/prowler/compliance/gcp/soc2_gcp.json @@ -78,12 +78,13 @@ { "ItemId": "cc_3_2", "Section": "CC3.0 - Common Criteria Related to Risk Assessment", - "Service": "gcr", + "Service": "gcp", "Type": "automated" } ], "Checks": [ - "gcr_container_scanning_enabled" + "gcr_container_scanning_enabled", + "gemini_api_disabled" ] }, { @@ -282,12 +283,13 @@ { "ItemId": "cc_7_1", "Section": "CC7.0 - System Operations", - "Service": "iam", + "Service": "gcp", "Type": "automated" } ], "Checks": [ - "iam_cloud_asset_inventory_enabled" + "iam_cloud_asset_inventory_enabled", + "gemini_api_disabled" ] }, { @@ -420,6 +422,22 @@ "iam_cloud_asset_inventory_enabled" ] }, + { + "Id": "cc_9_2", + "Name": "CC9.2 The entity assesses and manages risks associated with vendors and business partners", + "Description": "Establishes Requirements for Vendor and Business Partner Engagements—The entity establishes specific requirements for a vendor and business partner engagement that includes (1) scope of services and product specifications, (2) roles and responsibilities, (3) compliance requirements, and (4) service levels. Assesses Vendor and Business Partner Risks—The entity assesses, on a periodic basis, the risks that vendors and business partners (and those entities' vendors and business partners) represent to the achievement of the entity's objectives. Assigns Responsibility and Accountability for Managing Vendors and Business Partners—The entity assigns responsibility and accountability for the management of risks associated with vendors and business partners. Establishes Communication Protocols for Vendors and Business Partners—The entity establishes communication and resolution protocols for service or product issues related to vendors and business partners. Establishes Exception Handling Procedures From Vendors and Business Partners —The entity establishes exception handling procedures for service or product issues related to vendors and business partners. Assesses Vendor and Business Partner Performance—The entity periodically assesses the performance of vendors and business partners. Implements Procedures for Addressing Issues Identified During Vendor and Business Partner Assessments—The entity implements procedures for addressing issues identified with vendor and business partner relationships. Implements Procedures for Terminating Vendor and Business Partner Relationships — The entity implements procedures for terminating vendor and business partner relationships. Obtains Confidentiality Commitments from Vendors and Business Partners—The entity obtains confidentiality commitments that are consistent with the entity's confidentiality commitments and requirements from vendors and business partners who have access to confidential information. Assesses Compliance With Confidentiality Commitments of Vendors and Business Partners — On a periodic and as-needed basis, the entity assesses compliance by vendors and business partners with the entity's confidentiality commitments and requirements. Obtains Privacy Commitments from Vendors and Business Partners—The entity obtains privacy commitments, consistent with the entity's privacy commitments and requirements, from vendors and business partners who have access to personal information. Assesses Compliance with Privacy Commitments of Vendors and Business Partners— On a periodic and as-needed basis, the entity assesses compliance by vendors and business partners with the entity's privacy commitments and requirements and takes corrective action as necessary.", + "Attributes": [ + { + "ItemId": "cc_9_2", + "Section": "CC9.0 - Risk Mitigation", + "Service": "gcp", + "Type": "automated" + } + ], + "Checks": [ + "gemini_api_disabled" + ] + }, { "Id": "cc_a_1_1", "Name": "A1.2 The entity authorizes, designs, develops or acquires, implements, operates, approves, maintains, and monitors environmental protections, software, data back-up processes, and recovery infrastructure to meet its objectives", @@ -575,4 +593,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/prowler/compliance/github/cis_1.0_github.json b/prowler/compliance/github/cis_1.0_github.json index e675470169..e9dbccc7f6 100644 --- a/prowler/compliance/github/cis_1.0_github.json +++ b/prowler/compliance/github/cis_1.0_github.json @@ -499,7 +499,9 @@ { "Id": "1.2.3", "Description": "Ensure only a limited number of trusted users can delete repositories.", - "Checks": [], + "Checks": [ + "organization_repository_deletion_limited" + ], "Attributes": [ { "Section": "1 Source Code", diff --git a/prowler/compliance/googleworkspace/__init__.py b/prowler/compliance/googleworkspace/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json new file mode 100644 index 0000000000..fa32397826 --- /dev/null +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -0,0 +1,1882 @@ +{ + "Framework": "CIS", + "Name": "CIS Google Workspace Foundations Benchmark v1.3.0", + "Version": "1.3", + "Provider": "GoogleWorkspace", + "Description": "The CIS Google Workspace Foundations Benchmark provides prescriptive guidance for establishing a secure configuration posture for Google Workspace. This benchmark covers Directory, Devices, Apps, Security, Reporting, and Rules configurations.", + "Requirements": [ + { + "Id": "1.1.1", + "Description": "Ensure more than one Super Admin account exists", + "Checks": [ + "directory_super_admin_count" + ], + "Attributes": [ + { + "Section": "1 Directory", + "SubSection": "1.1 Users", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Having more than one Super Admin account is needed primarily so that a single point of failure can be avoided. Also, for larger organizations, having multiple Super Admins can be useful for workload balancing purposes.", + "RationaleStatement": "From a security point of view, having only a single Super Admin Account can be problematic if this user were unavailable for an extended period of time. Also, Super Admin accounts should never be shared amongst multiple users.", + "ImpactStatement": "There should be no user impact, but Administrators should have a normal (low privilege) and an Administrative (high privilege) account.", + "RemediationProcedure": "Create at least one additional account with a Super Admin role. NOTE: A new account should be created vs adding this role to an existing account since Administration tasks should be done through separate Admin accounts.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Directory and click on Users, this will show a list of all users 3. Click on + Add a filter, select Admin role, check the Super admin box, and then select Apply 4. The list of Users displayed will only be those with the Super Admin role 5. Make sure more than one (1) user is listed", + "AdditionalInformation": "", + "DefaultValue": "All Google Workspace tenants will have one Super Admin initially.", + "References": "" + } + ] + }, + { + "Id": "1.1.2", + "Description": "Ensure no more than 4 Super Admin accounts exist", + "Checks": [ + "directory_super_admin_count" + ], + "Attributes": [ + { + "Section": "1 Directory", + "SubSection": "1.1 Users", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Having more than one Super Admin account is needed primarily so that a single point of failure can be avoided, but having too many should be avoided.", + "RationaleStatement": "From a security point of view, having a large number of Super Admin accounts is a bad practice. In general, all users should be assigned the least privileges needed to do their job. This includes Administrators since not everyone that needs to \"Administer Something\" needs to be a Super Admin. Google Workspaces provides many predefined Administration Roles and also allows the creation of Custom Roles with very granular permission selection.", + "ImpactStatement": "There should be no user impact, but Administrators should have a normal (low privilege) and an Administrative (high privilege) account.", + "RemediationProcedure": "Reduce the number of accounts with a \"Super Admin\" role.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Directory and click on Users, this will show a list of all users 3. Click on + Add a filter, select Admin role, check the Super admin box, and then select Apply 4. The list of Users displayed will only be those with the Super Admin role 5. Make sure no more than four (4) users are listed", + "AdditionalInformation": "", + "DefaultValue": "All Google Workspace tenants will have one Super Admin initially.", + "References": "" + } + ] + }, + { + "Id": "1.1.3", + "Description": "Ensure super admin accounts are used only for super admin activities", + "Checks": [], + "Attributes": [ + { + "Section": "1 Directory", + "SubSection": "1.1 Users", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Super admin accounts have access to all features in the Google Admin console and Admin API and can manage every aspect of your organization's account. Super admins also have full access to all users' calendars and event details. It is recommended to give each super administrator two accounts. One for their super admin account and a second account for daily activities. Users should only sign in to a super admin account to perform super admin tasks, such as setting up 2-Step Verification (2SV), managing billing and user licenses, or helping another admin recover their account. Super administrators should use a separate, non-admin account for day- to-day activities. Super admins should sign in as needed to do specific tasks and then sign out. Leaving super admin accounts sign-in can increase exposure to phishing attacks.", + "RationaleStatement": "Use the super admin account only when needed. Delegate administrator tasks to user accounts with limited admin roles. Use the least privilege approach, where each user has access to the resources and tools needed for their typical tasks. For example, you could grant an admin permissions to create user accounts and reset passwords, but not let them delete user accounts.", + "ImpactStatement": "Super admin users will have to switch accounts as well as utilize login/logout functionality when performing administrative tasks.", + "RemediationProcedure": "For every Super admin that is also a Delegated admin account, either create a Delegated admin account for the user of elevate or their existing non-admin account to a Delegated admin account.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Directory and click on Users, this will show a list of all users 3. Click on + Add a filter, select Admin role, check the Super admin box, and then select Apply 4. The list of Users displayed will only be those with the Super Admin role 5. Click on + Add a filter, select Admin role, check the Delegated admin box, and then select Apply 6. Verify that there are no users in both the Super admin and Delegated admin roles", + "AdditionalInformation": "", + "DefaultValue": "N/A", + "References": "https://support.google.com/a/answer/179832?hl=en" + } + ] + }, + { + "Id": "1.2.1.1", + "Description": "Ensure directory data access is externally restricted", + "Checks": [], + "Attributes": [ + { + "Section": "1 Directory", + "SubSection": "1.2 Directory Settings", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace's external directory sharing to prevent unrestricted directory data access.", + "RationaleStatement": "If your organization uses third-party apps that integrate with your Google services, you control how much Directory information the external apps can access. If you allow directory access, your users have a better experience with external apps. For example, when they use a third-party mail app, they want to find domain contacts and have email addresses automatically complete. The app needs access to Directory data to make this happen. However, this has the ability to share ALL domain AND public data with the connected third-party app. Public data and authenticated user basic profile fields - Share publicly visible domain profile data with external apps and APIs. Also share the authenticated user's name, photo, and email address to enable Google Sign-In if the appropriate scopes are granted. Other non-public profile fields for the authenticated user aren't shared. All the non-public profile information of other users in the domain aren't shared. Domain and public data - (Default) Share all Directory information that's shared with your domain and public data. This information includes profile information for users in your domain, shared external contacts, and Google+ profile names and photos.", + "ImpactStatement": "The External directory sharing setting applies only to the following APIs and the Apps Scripts or third-party Marketplace apps that use those APIs: Google People API, Google CardDAV API, Google Contacts API v3. The setting applies only to third-party apps, such as iOS Mail and iOS Contacts (when enrolled on an iOS device via Add Account and then Google), third-party Contacts apps (on Android). The setting doesn't apply to Google products, including mobile apps, such as the following: Gmail, Contacts (on Android), Inbox, Meet, and other Google mobile apps; iOS Mail and iOS Contacts using Google Sync (when enrolled on an iOS device through Add Account and then Exchange); Workspace Sync for Microsoft Outlook.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Open the collapsed menu via \"hamburger button \\ 3 horizontal lines\" 3. Under Directory, select Directory settings 4. Under Sharing settings, select External Directory sharing 5. Select Public data and authenticated user basic profile fields", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Open the collapsed menu via \"hamburger button \\ 3 horizontal lines\" 3. Under Directory, select Directory settings 4. Under Sharing settings, select External Directory sharing 5. Ensure Domain and public data is not selected 6. Select Save", + "AdditionalInformation": "", + "DefaultValue": "• External Directory sharing = Domain and public data", + "References": "" + } + ] + }, + { + "Id": "3.1.1.1.1", + "Description": "Ensure external sharing options for primary calendars are configured", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control how much calendar information users in your organization can share externally.", + "RationaleStatement": "Prevent data leakage by restricting the amount of information that is externally viewable when a user shares their calendar with someone external to your organization.", + "ImpactStatement": "Once you limit external sharing for your organization, users can't exceed these limits when sharing individual events. For example, if you limit your organization's external sharing to Free/Busy, events with Public visibility are only shared as Free/Busy. External mobile users who previously synced events may keep seeing restricted details. That access stops when their device is wiped and re-synced. If you lower the external sharing level, people outside your organization may lose access to calendars they could previously see.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select External sharing options for primary calendars 6. Select Only free/busy information (hide event details) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select External sharing options for primary calendars 6. Ensure Only free/busy information (hide event details) is selected", + "AdditionalInformation": "", + "DefaultValue": "External sharing options for primary calendars is Only free/busy information (hide event details)", + "References": "" + } + ] + }, + { + "Id": "3.1.1.1.2", + "Description": "Ensure internal sharing options for primary calendars are configured", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Control how much calendar information users in your organization can share internally.", + "RationaleStatement": "In general, not everyone in the organization needs to know the schedule details of everyone else (operational security). Free/busy indication is enough for most people.", + "ImpactStatement": "This will be the default for the user's primary calendar. The user can override this setting to allow other specific users greater visibility of their calendar.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select Internal sharing options for primary calendars 6. Select Only free/busy information (hide event details) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select Internal sharing options for primary calendars 6. Ensure Only free/busy information (hide event details) is selected", + "AdditionalInformation": "", + "DefaultValue": "Internal sharing options for primary calendars is Share all information", + "References": "" + } + ] + }, + { + "Id": "3.1.1.1.3", + "Description": "Ensure external invitation warnings for Google Calendar are configured", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Calendar to warn users when inviting guest outside your domain.", + "RationaleStatement": "When your users create a Google Calendar event that includes one or more guests from outside of your domain, they are prompted to confirm whether it’s OK to include external guests in the event invitation, assisting in the prevention of unintentional data leakage.", + "ImpactStatement": "Users will be prompted to allow the inclusion of external guests in an event invitation.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select External Invitations 6. Set Warn users when inviting guests outside of the domain to checked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Sharing settings, select External invitations 6. Ensure Warn users when inviting guests outside of the domain is checked", + "AdditionalInformation": "", + "DefaultValue": "Warn users when inviting guests outside of the domain is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.1.2.1", + "Description": "Ensure external sharing options for secondary calendars are configured", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control how much calendar information users in your organization can share externally.", + "RationaleStatement": "Prevent data leakage by restricting the amount of information is externally viewable when a user shares their calendar with someone external to your organization.", + "ImpactStatement": "Once you limit external sharing for your organization, users can't exceed these limits when sharing individual events. For example, if you limit your organization's external sharing to Free/Busy, events with Public visibility are only shared as Free/Busy. External mobile users who previously synced events may keep seeing restricted details. That access stops when their device is wiped and re-synced. If you lower the external sharing level, people outside your organization may lose access to calendars they could previously see.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under General settings, select External sharing options for secondary calendars 6. Select Only free/busy information (hide event details) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under General settings, select External sharing options for secondary calendars 6. Ensure Only free/busy information (hide event details) is selected", + "AdditionalInformation": "", + "DefaultValue": "External sharing options for secondary calendars is Share all information, but outsiders cannot change calendars", + "References": "" + } + ] + }, + { + "Id": "3.1.1.2.2", + "Description": "Ensure internal sharing options for secondary calendars are configured", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Control how much calendar information users in your organization can share internally.", + "RationaleStatement": "In general, not everyone in the organization needs to know the schedule details of everyone else (operational security). Free/busy indication is enough for most people.", + "ImpactStatement": "This will be the default for the user's secondary calendars. The user can override this setting to allow other specific users greater visibility of their calendars.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under General settings, select Internal sharing options for secondary calendars 6. Select Only free/busy information (hide event details) 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under General settings, select Internal sharing options for secondary calendars 6. Ensure Only free/busy information (hide event details) is selected", + "AdditionalInformation": "", + "DefaultValue": "Internal sharing options for secondary calendars is Share all information", + "References": "" + } + ] + }, + { + "Id": "3.1.1.3.1", + "Description": "Ensure calendar web offline is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.1 Calendar", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Limit who is allowed offline calendar access.", + "RationaleStatement": "When enabled, users can turn on offline use for each computer they use. Data is stored on the computer until offline use is turned off by the user. In this case, the organization can lose control of where its data is stored (for this user). Care should be taken regarding which users and groups have this capability enabled.", + "ImpactStatement": "Users will not be able to access their calendars offline.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Advanced settings, select Calendar web offline 6. Set Allow using Calendar on the web when offline to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Calendar 5. Under Advanced settings, select Calendar web offline 6. Ensure Allow using Calendar on the web when offline is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow using Calendar on the web when offline is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.1", + "Description": "Ensure users are warned when they share a file outside their domain", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Warn the user when they try and share a file and/or shared drive externally.", + "RationaleStatement": "The user may not realize the potential account is external to the organization. Providing a warning allows the user an opportunity to know this and possibly reassess this sharing.", + "ImpactStatement": "None, except an additional warning. Sharing can still occur.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Sharing outside of 7. Set ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well. to checked. Also, set the sub-setting For files owned by users in warn when sharing outside of to checked. 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Sharing outside of 7. Ensure ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well. is checked. Also, ensure the sub-setting For files owned by users in warn when sharing outside of is checked.", + "AdditionalInformation": "", + "DefaultValue": "For files owned by users in warn when sharing outside of is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.2", + "Description": "Ensure users cannot publish files to the web or make visible to the world as public or unlisted", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "You should control the publishing of documents to the web or making them visable to the world as public or unlisted.", + "RationaleStatement": "Attackers will often attempt to expose sensitive information to external entities through sharing, and restricting the methods that your users can share documents with will reduce that surface area. This setting is only applicable if ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well is selected, but should be configured as described below to prevent unintentional document publishing.", + "ImpactStatement": "Enabling this feature will prevent users from publishing documents on the web or making them visible to the world as public or unlisted files.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Sharing outside of - ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well, set When sharing outside of is allowed, users in can make files and published web content visible to anyone with the link to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Sharing outside of - ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well, ensure When sharing outside of is allowed, users in can make files and published web content visible to anyone with the link is unchecked", + "AdditionalInformation": "", + "DefaultValue": "When sharing outside of is allowed, users in can make files and published web content visible to anyone with the link is Checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.3", + "Description": "Ensure document sharing is being controlled by domain with allowlists", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "You should control sharing of documents to external domains by either blocking domains or only allowing sharing with specific named domains.", + "RationaleStatement": "Attackers will often attempt to expose sensitive information to external entities through sharing, and restricting the domains that your users can share documents with will reduce that surface area.", + "ImpactStatement": "Enabling this feature will prevent users from sharing documents with domains outside of the organization unless allowed.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Sharing outside of , select ALLOWLISTED DOMAINS - Files owned by users in can be shared with Google Accounts in compatible allowlisted domains. 7. Set Warn when files owned by users or shared drives in are shared with users in allowlisted domains to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Sharing outside of , ensure ALLOWLISTED DOMAINS - Files owned by users in can be shared with Google Accounts in compatible allowlisted domains. is selected 7. Ensure Warn when files owned by users or shared drives in are shared with users in allowlisted domains is checked", + "AdditionalInformation": "", + "DefaultValue": "Sharing outside of is ON - Files owned by users in can be shared outside of . This applies to files in all shared drives as well.", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.4", + "Description": "Ensure users are warned when they share a file with users in an allowlisted domain", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Warn the user when they try and share a file and/or shared drive with users in an allowlisted domain.", + "RationaleStatement": "The user may not realize the potential account is external to the organization. Providing a warning allows the user an opportunity to know this and possibly reassess this sharing.", + "ImpactStatement": "None, except an additional warning. Sharing can still occur.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Sharing outside of 7. Set ALLOWLISTED DOMAINS - Files owned by users or shared drives in BMDT-Group can be shared with Google accounts in compatible allowlisted domains. to checked. Also, set the sub-setting Warn when files owned by users or shared drives in are shared with users in allowlisted domains to checked. 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Sharing outside of 7. Ensure ALLOWLISTED DOMAINS - Files owned by users or shared drives in BMDT-Group can be shared with Google accounts in compatible allowlisted domains is checked. Also, ensure the sub-setting Warn when files owned by users or shared drives in are shared with users in allowlisted domains is checked.", + "AdditionalInformation": "", + "DefaultValue": "For files owned by users in warn when sharing outside of is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.5", + "Description": "Ensure Access Checker is configured to limit file access", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "When a user shares a file via a Google product other than Docs or Drive (e.g. by pasting a link in Gmail), Google can check that the recipients have access. If not, when possible, Google will ask the user to pick how they want to share the file.", + "RationaleStatement": "In general, access should be restricted to the smallest group possible. In this case recipients only.", + "ImpactStatement": "Only recipients can access files. Recipients cannot share access with others by forwarding the email/link.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Access Checker 7. Set Recipients only. to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Drive and Docs 4. Select Sharing Settings 5. Select Sharing Options 6. Under Access Checker 7. Ensure Recipients only. is checked", + "AdditionalInformation": "", + "DefaultValue": "Recipients only, suggested target audience, or public (no Google account required). is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.1.6", + "Description": "Ensure only users inside your organization can distribute content externally", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "You should control who is allowed to distribute organizational content to shared drives owned by another organization.", + "RationaleStatement": "Sharing and collaboration are key; however, only your users should have the authority over where company content is shared with to prevent unauthorized disclosures of information.", + "ImpactStatement": "Only people in your organization with Manager access to a shared drive can move files from that shared drive to a Drive location in a different organization. In addition, users in the selected organizational unit or group can copy content from their My Drive to a shared drive owned by a different organization.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Distributing content outside of , select - Only users in 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Sharing options 6. Under Distributing content outside of , ensure Only users in is selected", + "AdditionalInformation": "", + "DefaultValue": "Distributing content outside of is Anyone", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.2.1", + "Description": "Ensure users can create new shared drives", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "All users should have the ability to create new shared drives.", + "RationaleStatement": "By default, when a user account is deleted all the data in their personal drive is deleted as well. By allowing any user to create new shared drives aids in preventing data loss when user accounts are deleted.", + "ImpactStatement": "Disabling this feature will prevent users from creating new shared drives.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Shared drive creation 6. Set Prevent users in from creating new shared drives to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Under Sharing settings, select Shared drive creation 6. Ensure Prevent users in from creating new shared drives is un-checked", + "AdditionalInformation": "", + "DefaultValue": "Prevent users in from creating new shared drives is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.2.2", + "Description": "Ensure manager access members cannot modify shared drive settings", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Only administrators should be able to modify shared drive settings.", + "RationaleStatement": "Allowing manager access members to override or modify shared drive settings can allow intentional and unintentional data access by unauthorized users.", + "ImpactStatement": "Disabling this feature will prevent manager access members from modifying shared drive settings, requiring administrators to perform settings modifications as required.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, set Allow members with manager access to override the settings below to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, ensure Allow members with manager access to override the settings below is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow members with manager access to override the settings below is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.2.3", + "Description": "Ensure shared drive file access is restricted to members only", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Shared drive file access should be restricted to that shared drive's members", + "RationaleStatement": "Preventing unauthorized users from access sensitive data is paramount in preventing unauthorized or unintentional information disclosures.", + "ImpactStatement": "Disabling this feature will prevent shared drive non-members from accessing content in shared drives where they are not a member.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, set Allow people who aren't shared drive members to be added to files to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, ensure Allow people who aren't shared drive members to be added to files is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow people who aren't shared drive members to be added to files is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.1.2.4", + "Description": "Ensure viewers and commenters ability to download, print, and copy files is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "limit what viewers/commenters on a shared document can do with it.", + "RationaleStatement": "In many cases when sharing a document it might be fine for the users to do what they want with the document on the shared drive (Download, Print, etc.). In more restricted environments these capabilities may need to be prevented (Protected Intellectual property, Personally Identifiable Information, etc.).", + "ImpactStatement": "Users of this shared drive will be restricted to only reading and commenting on the existing files.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, set Allow viewers and commenters to download, print, and copy files to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Sharing settings 6. Under Shared drive creation, ensure Allow viewers and commenters to download, print, and copy files is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow viewers and commenters to download, print, and copy files is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.2.1", + "Description": "Ensure offline access to documents is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Prevent documents from being locally accessible on an unconnected device.", + "RationaleStatement": "This setting prevents an organization's files from being stored locally, thus limiting data loss issues if the device is lost or stolen.", + "ImpactStatement": "Copies of recent files are only synced and saved on devices if you've defined a managed policy to do so. NOTE: All users will lose access to offline documents on all devices if managed devices policies are not set. NOTE: Setting up policies to control offline access on individual devices is outside the scope of this Benchmark. Additional information om doing this for various device types can be found here.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Features and Applications 6. Select Offline 7. Set Control offline access using device policies. to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Features and Applications 6. Select Offline 7. Ensure Control offline access using device policies is checked", + "AdditionalInformation": "", + "DefaultValue": "Control offline access using device policies is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.2.2", + "Description": "Ensure desktop access to Drive is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Prevent documents from being locally accessible on an unconnected device.", + "RationaleStatement": "This setting prevents an organization's files from being stored locally, thus limiting data loss issues if the device is lost or stolen. NOTE: The Google Drive desktop application has its own way of handling \"Offline\" files and does not obey the Drive and Doc > Offline > Control offline access using divide policies setting. Not allowing Google Drive for desktop on the device will prevent this channel.", + "ImpactStatement": "The end user will not be able to use Google Drive for desktop and its convenient integration into the Windows file explorer.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Features and Applications 6. Select Google Drive for desktop 7. Set Allow Google Drive for desktop in your organization to unchecked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Features and Applications 6. Select Google Drive for desktop 7. Ensure Allow Google Drive for desktop in your organization is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow Google Drive for desktop in your organization is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.2.2.3", + "Description": "Ensure Add-Ons is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.2 Drive and Docs", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Prevent users to install Google Docs add-ons from add-ons store. NOTE: This setting controls add-on access from outside your organization.", + "RationaleStatement": "Allowing uses to install unapproved Add-Ons puts the organization at risk. If users need a specific Add-On this can be handled on a case by case basis as the need, and the add-on, is approved.", + "ImpactStatement": "The end user will not be able to use Google Drive for desktop and its convenient integration into the Windows file explorer.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Features and Applications 6. Select Add-Ons 7. Set Allow users to install Google Docs add-ons from add-ons store. to unchecked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Drive and Docs 5. Select Features and Applications 6. Select Add-Ons 7. Ensure Allow users to install Google Docs add-ons from add-ons store. is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow users to install Google Docs add-ons from add-ons store. is checked", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F4530135&assistant_id=generic- unu&product_context=4530135&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "3.1.3.1.1", + "Description": "Ensure users cannot delegate access to their mailbox", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Mail delegation allows the delegate to read, send, and delete messages on their behalf. For example, a manager can delegate Gmail access to another person in their organization, such as an administrative assistant.", + "RationaleStatement": "Only administrators should be able to delegate access to a user's mailboxes.", + "ImpactStatement": "Existing delegations will be hidden, when this feature is disabled.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under User Settings - Mail delegation, set Let users delegate access to their mailbox to other users in the domain to unchecked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under User Settings - Mail delegation, ensure Let users delegate access to their mailbox to other users in the domain is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Let users delegate access to their mailbox to other users in the domain is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.1.2", + "Description": "Ensure offline access to Gmail is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Disables the user's ability to utilize various Gmail functions (read, write, search, delete, and label email messages) while not connected to the internet.", + "RationaleStatement": "Prevents the organization's data (user's email) from being copied to remote computers.", + "ImpactStatement": "Users will need internet access to use Gmail.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Gmail 4. Select User Settings 5. SelectGmail web offline 6. Set Enable Gmail web offline to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Gmail 4. Select User Settings 5. Under Gmail web offline 6. Ensure Enable Gmail web offline is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Enable Gmail web offline is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.2.1", + "Description": "Ensure that DKIM is enabled for all mail enabled domains", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "DKIM adds an encrypted signature to the header of all outgoing messages. Email servers that get signed messages use DKIM to decrypt the message header, and verify the message was not changed after it was sent.", + "RationaleStatement": "Spoofing is a common unauthorized use of email, so some email servers require DKIM to prevent email spoofing.", + "ImpactStatement": "There should be no impact of setting up DKIM however, organizations should ensure appropriate setup to ensure continuous mail-flow.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Authenticate email, select - Generate new record 6. Under Select DKIM key bit length, select the appropriate key bit length 2048 is recommended if supported 7. Under Prefix selector (optional), enter the appropriate prefix selector 8. Use the text at TXT record value to update the DNS record at your domain host 9. Select Start Authentication", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Authenticate email, ensure a DKIM record exists for each mail enabled domain", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "3.1.3.2.2", + "Description": "Ensure the SPF record is configured for all mail enabled domains", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "For all the email domains configured in Google Workspace, a corresponding Sender Policy Framework (SPF) record should be created. NOTE: There are a number of ways SPF can be configured, this document presents a most basic method. For more information on setting up SPF for Google Workspace please refer to the Google documentation. • How SPF protects against spoofing and spam • Define your SPF record—Basic setup", + "RationaleStatement": "SPF records allow Gmail and other mail systems to know where messages from your domains are allowed to originate. This information can be used by that system to determine how to treat the message based on if it is being spoofed or is valid.", + "ImpactStatement": "There should be minimal impact of setting up SPF records however, organizations should ensure proper SPF record setup as email could be flagged as spam if SPF is not set up appropriately.", + "RemediationProcedure": "Configure the DNS record for each domain. • If all email in your domain is sent from and received by Google Gmail, add the following TXT record for each domain: v=spf1 include:_spf.google.com ~all NOTE: This will likely need to be configured at your domain registrar (Godaddy, etc.).", + "AuditProcedure": "Check the DNS records for each domain. 1. Use a Domain Name System (DNS) lookup tool to review the current configuration for your domain (DNS Records). This information can be discovered in a variety of ways: o Reviewing the DNS Record information at your domain registrar (GoDaddy, etc.) o Using an OS based nslookup tool on your workstation OS o Using Google Dig tool available from the Google Admin Toolbox site (Link: Dig) 2. Using the chosen tool, enter your email domain name (ex. domain1.com) 3. In the results displayed, ensure that a TXT Record with the value of v=spf1 include:_spf.google.com ~all exists and designates Google Gmail as a authorized sender.", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "3.1.3.2.3", + "Description": "Ensure the DMARC record is configured for all mail enabled domains", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "For all email domains configured in Google Workspace, a corresponding Domain-Based Message Authentication, Reporting and Conformance (DMARC) record should be created. NOTE: There are a number of ways DMARC can be configured, this document presents a most basic method. For more information on setting up DMARC for Google Workspace please refer to the Google documentation. • Help prevent spoofing and spam with DMARC • Tutorial: Recommended DMARC rollout", + "RationaleStatement": "DMARC works with Sender Policy Framework (SPF) and Domain Keys Identified Mail (DKIM) to authenticate mail senders and ensure that destination email systems trust messages sent from your domain. Spammers can spoof your domain or organization to send fake messages that impersonate your organization. DMARC tells receiving mail servers what to do when they get a message that appears to be from your organization, but doesn't pass authentication checks, or doesn’t meet the authentication requirements", + "ImpactStatement": "There should be minimal impact of setting up DMARC records however, organizations should ensure proper DMARC record setup as email could be flagged as spam if DMARC is not set up appropriately.", + "RemediationProcedure": "Configure the DNS record for each domain. 1. If all email in your domain is sent from and received by Google Gmail, add the following TXT record for the domain: v=DMARC1; p=none; rua=mailto: NOTE: This will likely need to be configured at your domain registrar (Godaddy, etc.).", + "AuditProcedure": "Check the DNS records for each domain. 1. Use a Domain Name System (DNS) lookup tool to review the current configuration for your domain (DNS Records). This information can be discovered in a variety of ways: o Reviewing the DNS Record information at your domain registrar (GoDaddy, etc.) o Using an OS based nslookup tool on your workstation OS o Preferred: Using Google Dig tool available from the Google Admin Toolbox site (Link: Dig) 2. Using the chosen tool, enter your email domain name (ex. domain1.com) 3. In the results displayed, ensure that a TXT Record with the value of v=DMARC1; p=none; rua=mailto: exists. This designates Google Gmail as an authorized sender. NOTE: The p=none sets DMARC to non-enforcing. This is a relaxed DMARC policy that lets you start getting reports without risking messages from your domain being rejected or marked as spam by receiving servers. Start with a none policy that only monitors email flow, and then eventually change to a policy", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "3.1.3.3.1", + "Description": "Enable quarantine admin notifications for Gmail", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Quarantines can help prevent spam, minimize data loss, and protect confidential information. They can also help moderate message attachments so users don’t send, open, or click something they shouldn’t.", + "RationaleStatement": "Admins should be notified periodically when messages are quarantined so they can take the appropriate actions.", + "ImpactStatement": "Admins will begin receiving quarantine notifications as emails are quarantined.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Manage quarantines, set Notify periodically when messages are quarantined to checked As required, give appropriate users the Access Admin Quarantine and\\or Access restricted quarantine roles", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Manage quarantines, ensure each quarantine has Notify periodically when messages are quarantined is checked", + "AdditionalInformation": "", + "DefaultValue": "Notify periodically when messages are quarantined is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.1.1", + "Description": "Ensure protection against encrypted attachments from untrusted senders is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As a Google Workspace administrator, you can protect incoming mail against phishing and harmful software (malware). You can also choose what action to take based on the type of threat detected.", + "RationaleStatement": "You should protect your users from potentially malicious attachments.", + "ImpactStatement": "Users will be warned when they receive an encrypted attachment from an untrusted sender.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, set Protect against encrypted attachments from untrusted senders to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, ensure Protect against encrypted attachments from untrusted senders is checked", + "AdditionalInformation": "", + "DefaultValue": "Protect against encrypted attachments from untrusted senders is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.1.2", + "Description": "Ensure protection against attachments with scripts from untrusted senders is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As a Google Workspace administrator, you can protect incoming mail against phishing and harmful software (malware). You can also choose what action to take based on the type of threat detected.", + "RationaleStatement": "You should protect your users from potentially malicious attachments.", + "ImpactStatement": "Users will be warned when they receive an attachments with scripts from an untrusted sender.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, set Protect against attachments with scripts from untrusted senders to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, ensure Protect against attachments with scripts from untrusted senders is checked", + "AdditionalInformation": "", + "DefaultValue": "Protect against attachments with scripts from untrusted senders is enabled is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.1.3", + "Description": "Ensure protection against anomalous attachment types in emails is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As a Google Workspace administrator, you can protect incoming mail against phishing and harmful software (malware). You can also choose what action to take based on the type of threat detected.", + "RationaleStatement": "You should protect your users from potentially malicious attachments.", + "ImpactStatement": "Users will be warned when they receive an anomalous attachment.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, set Protect against anomalous attachment types in emails to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Attachments, ensure Protect against anomalous attachment types in emails is checked", + "AdditionalInformation": "", + "DefaultValue": "Protect against anomalous attachment types in emails is Unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.2.1", + "Description": "Ensure link identification behind shortened URLs is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Identify links behind short URLs, and display a warning when you click links to untrusted domains.", + "RationaleStatement": "You should protect your users from potentially malicious links.", + "ImpactStatement": "Users will be warned when they click links to untrusted domains.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, set Identify links behind shortened URLs to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, ensure Identify links behind shortened URLs is checked", + "AdditionalInformation": "", + "DefaultValue": "Identify links behind shortened URLs is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.2.2", + "Description": "Ensure scan linked images for malicious content is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Scan linked images for malicious content, and display a warning when you click links to untrusted domains.", + "RationaleStatement": "You should protect your users from potentially malicious links.", + "ImpactStatement": "Users will be warned when they click links to untrusted domains.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, set Scan linked images to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, ensure Scan linked images is checked", + "AdditionalInformation": "", + "DefaultValue": "Scan linked images is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.2.3", + "Description": "Ensure warning prompt is shown for any click on links to untrusted domains", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Display a warning when you click links to untrusted domains.", + "RationaleStatement": "You should protect your users from potentially malicious links.", + "ImpactStatement": "Users will be warned when they click links to untrusted domains.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, set Show warning prompt for any click on links to untrusted domains is checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Links and external images, ensure Show warning prompt for any click on links to untrusted domains is checked", + "AdditionalInformation": "", + "DefaultValue": "Show warning prompt for any click on links to untrusted domains is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.1", + "Description": "Ensure protection against domain spoofing based on similar domain names is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Moves domain spoofing emails to spam folder.", + "RationaleStatement": "You should protect your users from domain spoofing emails.", + "ImpactStatement": "Domain spoofed emails will be moved to a user's spam folder.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect against domain spoofing based on similar domain names to checked 6. Set Action to Move email to spam 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect against domain spoofing based on similar domain names is checked 6. Ensure Action is Move email to spam", + "AdditionalInformation": "", + "DefaultValue": "• Protect against domain spoofing based on similar domain names is checked • Action is Keep email in inbox and show warning (default)", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.2", + "Description": "Ensure protection against spoofing of employee names is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Moves employee spoofing emails to spam folder.", + "RationaleStatement": "You should protect your users from employee spoofing emails.", + "ImpactStatement": "Employee spoofed emails will be moved to a user's spam folder.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect against spoofing of employee names to checked 6. Set Action to Move email to spam 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect against spoofing of employee names is checked 6. Ensure Action is Move email to spam", + "AdditionalInformation": "", + "DefaultValue": "• Protect against spoofing of employee names = checked • Action = Keep email in inbox and show warning (default)", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.3", + "Description": "Ensure protection against inbound emails spoofing your domain is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Moves inbound emails spoofing your domain to spam folder.", + "RationaleStatement": "You should protect your users from inbound company domain spoofing emails.", + "ImpactStatement": "Inbound company domain spoofed emails will be moved to a user's spam folder.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect against inbound emails spoofing your domain to checked 6. Set Action to Move email to spam 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect against inbound emails spoofing your domain is checked 6. Ensure Action is Move email to spam", + "AdditionalInformation": "", + "DefaultValue": "• Protect against inbound emails spoofing your domain = checked • Action = Keep email in inbox and show warning (default)", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.4", + "Description": "Ensure protection against any unauthenticated emails is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Displays a warning when any message is not authenticated (SPF or DKIM).", + "RationaleStatement": "You should protect your users from any emails that aren't authenticated (SPF or DKIM)", + "ImpactStatement": "Emails that aren't authenticated (SPF or DKIM) display a warning message to the recipient.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect against any unauthenticated emails to checked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect against any unauthenticated emails is checked", + "AdditionalInformation": "", + "DefaultValue": "Protect against any unauthenticated emails = unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.4.3.5", + "Description": "Ensure groups are protected from inbound emails spoofing your domain", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "If a group receives an email that is spoofing your domain it is sent to the spam folder.", + "RationaleStatement": "You should protect your groups from any emails that spoofing your domain.", + "ImpactStatement": "Emails that are spoofing your domain and are received by a group are sent to the spam folder.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, set Protect your Groups from inbound emails spoofing your domain to checked 6. Set Action to Move email to spam 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under Safety - Spoofing and authentication, ensure Protect your Groups from inbound emails spoofing your domain is checked 6. Ensure Action is set to Move email to spam", + "AdditionalInformation": "", + "DefaultValue": "• Protect against any unauthenticated emails = unchecked • Action = Keep email in inbox and display warning (default)", + "References": "" + } + ] + }, + { + "Id": "3.1.3.5.1", + "Description": "Ensure POP and IMAP access is disabled for all users", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "POP and IMAP may allow users to access Gmail using legacy or unapproved email clients that do not support modern authentication mechanisms, such as multifactor authentication.", + "RationaleStatement": "Disabling POP and IMAP prevents use of legacy and unapproved email clients with weaker authentication mechanisms that would increase the risk of email account credential compromise.", + "ImpactStatement": "If you have Apple iOS or Android device users in your organization and you turn IMAP off, let them know that they’re no longer syncing Google Workspace mail to the iOS or Android Mail app. They might not get a notification on their device. Additionally, new users can’t manually add the Google Account they use for work or school to the device. If your Google Workspace users want to use desktop clients, such as Microsoft Outlook and Apple Mail, to access their Google Workspace mail, you need to en", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - POP and IMAP Access 6. Set Enable IMAP access for all users to unchecked 7. Set Enable POP access for all users to unchecked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - POP and IMAP Access 6. Ensure Enable IMAP access for all users is unchecked 7. Ensure Enable POP access for all users is unchecked", + "AdditionalInformation": "", + "DefaultValue": "• Enable IMAP access for all users is checked • Enable POP access for all users is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.5.2", + "Description": "Ensure automatic forwarding options are disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "You should disable automatic forwarding to prevent users from auto-forwarding mail.", + "RationaleStatement": "In the event that an attacker gains control of an end-user account they could create rules to ex-filtrate data from your environment.", + "ImpactStatement": "Care should be taken before implementation to ensure there is no business need for case-by-case auto-forwarding. Disabling auto-forwarding to remote domains will affect all users and in an organization.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - Automatic forwarding, set Allow users to automatically forward incoming email to another address to unchecked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - Automatic forwarding, ensure Allow users to automatically forward incoming email to another address is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow users to automatically forward incoming email to another address is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.5.3", + "Description": "Ensure per-user outbound gateways is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "A per-user outbound gateway is a mail server, other than the Google Workspace mail servers, that delivers outgoing mail for a user in your domain.", + "RationaleStatement": "Mail sent via external SMTP will circumvent your outbound gateway", + "ImpactStatement": "Care should be taken before implementation to ensure there is no business need for mail sent via external SMTP gateway.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - Allow per-user outbound gateways, set Allow users to send mail through an external SMTP server when configuring a \"from\" address hosted outside your email domain to unchecked 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Under End User Access - Allow per-user outbound gateways, ensure Allow users to send mail through an external SMTP server when configuring a \"from\" address hosted outside your email domain is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Allow users to send mail through an external SMTP server when configuring a \"from\" address hosted outside your email domain is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.5.4", + "Description": "Ensure external recipient warnings are enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Gmail adds an image or colored border to external addresses.", + "RationaleStatement": "As an admin for your organization, you can turn alerts on or off for messages that include external recipients (people with email addresses outside of your organization). These alerts help people avoid unintentional replies, and remind them to treat external messages with caution.", + "ImpactStatement": "When this setting is on, Gmail shows warnings (colored boarder) when: • An email thread includes external recipients (not available on iOS). • Replying to a message from an external recipient. • Composing a new message to an external recipient (not available on iOS). Gmail doesn't show a warning if the external recipient is in your organization's Directory, personal Contacts, or other Contacts. Warnings aren't displayed for secondary domain or domain alias addresses.", + "RemediationProcedure": "To configure external recipient warnings are enabled, use the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select End User Access 6. Select Warn for external recipients 7. Set Highlight any external recipients in a conversation. Warn users before they reply to email with external recipients who aren't in their contacts. to checked 8. Select Save", + "AuditProcedure": "To verify Ensure external recipient warnings are enabled, use the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select End User Access 6. Under Warn for external recipients, ensure Highlight any external recipients in a conversation. Warn users before they reply to email with external recipients who aren't in their contacts. is ON", + "AdditionalInformation": "", + "DefaultValue": "Highlight any external recipients in a conversation. Warn users before they reply to email with external recipients who aren't in their contacts. is ON", + "References": "" + } + ] + }, + { + "Id": "3.1.3.6.1", + "Description": "Ensure enhanced pre-delivery message scanning is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enables improved detection of suspicious content prior to delivery.", + "RationaleStatement": "As an administrator, you can increase Gmail's ability to identify suspicious content with enhanced pre-delivery message scanning. Typically, when Gmail identifies a possible phishing message, a warning is displayed and the message might be moved to spam.", + "ImpactStatement": "With the Enhanced pre-delivery message scanning option, when Gmail detects suspicious content, message delivery is slightly delayed so that Gmail can do additional security checks on the message.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Spam, phishing, and malware 6. Select Enhanced pre-delivery message scanning. 7. Set Enables improved detection of suspicious content prior to delivery to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Spam, phishing, and malware 6. Ensure Enhanced pre-delivery message scanning. is ON", + "AdditionalInformation": "", + "DefaultValue": "Enhanced pre-delivery message scanning. is ON", + "References": "" + } + ] + }, + { + "Id": "3.1.3.6.2", + "Description": "Ensure spam filters are not bypased for internal senders", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "You can configure your advanced Gmail settings to bypass, or not bypass, spam filters for messages received from internal senders.", + "RationaleStatement": "Turning off this setting reduces the risk of spoofing and phishing/whaling.", + "ImpactStatement": "Your users will be better protected by filtering their email for spam and minimizing the chances for spoofing and phishing/whaling attacks.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Spam, phishing, and malware 6. Under Spam, select Configure 7. Set Bypass spam filters for messages received from internal senders. to unchecked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Spam, phishing, and malware 6. Under Spam, select Configure 7. Ensure Bypass spam filters for messages received from internal senders. is unchecked", + "AdditionalInformation": "", + "DefaultValue": "Bypass spam filters for messages received from internal senders. is checked", + "References": "" + } + ] + }, + { + "Id": "3.1.3.7.1", + "Description": "Ensure comprehensive mail storage is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Comprehensive mail storage ensures messages sent by other core services appear in users' sent folders and are therefore accessible to Vault.", + "RationaleStatement": "As an administrator, you can ensure that a copy of all sent or received messages in your domain—including messages sent or received by non-Gmail mailboxes—is stored in the associated users' Gmail mailboxes.", + "ImpactStatement": "There are some important considerations to carefully review before enabling comprehensive mail storage: • You should not enable comprehensive mail storage if you have compliance routing rules that change the recipient (and don’t want the original recipient to receive a copy of the email). • When you have the SMTP Relay service enabled, user mailboxes will keep a copy of the message in the sent folder (for example, when sending mail from a scanner) if comprehensive mail storage is enabled. This m", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Compliance 6. Select Comprehensive mail storage 7. Set Ensure that a copy of all sent and received mail is stored in associated users' mailboxes to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Compliance 6. Under Comprehensive mail storage, ensure Ensure that a copy of all sent and received mail is stored in associated users' mailboxes is ON", + "AdditionalInformation": "", + "DefaultValue": "Copy of all sent and received mail is stored in associated users' mailboxes is OFF", + "References": "" + } + ] + }, + { + "Id": "3.1.3.7.2", + "Description": "Ensure 'Send email over a secure TLS connection' Is Enabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.3 Gmail", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "The default is that Gmail always tries to send messages over a secure TLS connection. If the receiving server doesn't use TLS, Gmail still sends messages with TLS but the connection isn't secure. This setting allows the option to require a CA-signed certificate, verify the hostname associated with the certificate, and test the TLS connection. A padlock image will appear next to the recipient address if the message will be sent with TLS. The padlock shows only for accounts with a Google Workspace subscription that supports S/MIME encryption. Google Workspace supports TLS versions 1.0, 1.1, 1.2, and 1.3.", + "RationaleStatement": "Transport Layer Security (TLS) encrypts email messages for security and privacy and prevents unauthorized access of messages when they're sent over internet connections.", + "ImpactStatement": "This should not have an impact on the usage of Gmail.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Compliance 6. Select Secure transport (TLS) compliance 7. Select Configure 8. Set Inbound - all messages and Outbound - all messages to checked 9. Select Save Note: Enabling the Inbound - all messages and Outbound - all messages configurations will also, by default, enable Require CA-signed certificate when delivering outbound messages to the TLS-enabled domains specified above. This is not a required configuration, but it is recommended.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Gmail 5. Select Compliance 6. Under Secure transport (TLS) compliance, select Configure 7. Under Email messages to affect ensure Inbound - all messages and Outbound - all messages are ON", + "AdditionalInformation": "", + "DefaultValue": "", + "References": "https://support.google.com/a/answer/2520500" + } + ] + }, + { + "Id": "3.1.4.1.1", + "Description": "Ensure external filesharing in Google Chat and Hangouts is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control how files are shared externally in Google Chat and Hangouts.", + "RationaleStatement": "Files often contain confidential information, and some organizations, particularly in regulated industries, need to control the flow of this information within and outside of their organization.", + "ImpactStatement": "Users will not be able to share files via chat externally.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat File Sharing 5. Under Setting, set External filesharing to No files 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat File Sharing 5. Under Setting, verify External filesharing is set to No files", + "AdditionalInformation": "", + "DefaultValue": "External filesharing is Allow all files", + "References": "" + } + ] + }, + { + "Id": "3.1.4.1.2", + "Description": "Ensure internal filesharing in Google Chat and Hangouts is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Control how files are shared internally in Google Chat and Hangouts.", + "RationaleStatement": "Files often contain confidential information, and some organizations, particularly in regulated industries, need to control the flow of this information within and outside of their organization.", + "ImpactStatement": "Users will not be able to share files via chat internally.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat File Sharing 5. Under Setting, set Internal filesharing to No files 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat File Sharing 5. Under Setting, verify Internal filesharing is set to No files", + "AdditionalInformation": "", + "DefaultValue": "Internal filesharing is Allow all files", + "References": "" + } + ] + }, + { + "Id": "3.1.4.2.1", + "Description": "Ensure Google Chat externally is restricted to allowed domains", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control how users chat with people outside of your organization. If you allow your users to chat externally, you can also allow them to create and join spaces with people outside your organization.", + "RationaleStatement": "Restricting external chat to only approved domains potentially limits the spread of company information.", + "ImpactStatement": "Users will not be able to chat with users in any external domain, only approved domains. This will require some admin-level approval and allowlist maintenance.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select External Chat Settings 5. Select Chat externally 6. Set Allow users to send messages outside to ON 7. Set Only allow this for allowlisted domains to checked 8. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select External Chat Settings 5. Select Chat externally 6. Verify Allow users to send messages outside is ON 7. Verify Only allow this for allowlisted domains is checked", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to send messages outside is set to ON • Only allow this for allowlisted domains is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.4.3.1", + "Description": "Ensure external spaces in Google Chat and Hangouts are restricted", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control whether users can create or join spaces within your organization that include external people outside of your organization.", + "RationaleStatement": "Restricting external spaces to only approved domains potentially limits the spread of company information.", + "ImpactStatement": "Users with this setting turned off or who have editions that don't support external spaces can't create these spaces, but they can join existing spaces with external people", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select External Spaces 5. Under Setting, set Allow users at to create and join spaces with people outside their organization to ON 6. Set Only allow users to add people from allowlisted domains to checked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select External Spaces 5. Under Setting, verify Allow users at to create and join spaces with people outside their organization is ON 6. Verify Only allow users to add people from allowlisted domains is checked", + "AdditionalInformation": "", + "DefaultValue": "• Allow users at to create and join spaces with people outside their organization is ON • Only allow users to add people from allowlisted domains is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.4.4.1", + "Description": "Ensure allow users to install Chat apps is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control the use of Chat apps in spaces or direct messages to connect to services in Google Chat and look up information, schedule meetings, or complete tasks. Apps are accounts created by Google, users in your organization, or third parties.", + "RationaleStatement": "When a user interacts with an app in Chat, the app can see the user's email address, avatar, other basic user information, user locale, timezone, and interaction information. The app can also see the basic user information of other people in the chat, but it can't see their email address or avatar unless they also interact directly with the app. Chat apps that you install from the Google Workspace Marketplace can be made by developers from outside of your organization. Using these Chat apps need to be carefully controlled (vetted and approved) since a malicious Chat app could allow the exfiltration of company proprietary information.", + "ImpactStatement": "By default users will not be able to install Chat apps.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat apps 5. Under Chat apps access settings, set Allow users to install Chat apps to OFF 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat apps 5. Under Chat apps access settings, verify Allow users to install Chat apps is OFF", + "AdditionalInformation": "", + "DefaultValue": "Allow users to install Chat apps is ON", + "References": "https://developers.google.com/chat/concepts/apps" + } + ] + }, + { + "Id": "3.1.4.4.2", + "Description": "Ensure allow users to add and use incoming webhooks is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.4 Google Chat", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Allow users to configure incoming webhooks and developers to call incoming webhooks to post content. Incoming webhooks let you send asynchronous messages into Google Chat from applications that aren't Chat apps.", + "RationaleStatement": "Webhook usage should be carefully controlled (vetted and approved) since a malicious application could send bogus information to exposed webhooks and ultimately these users.", + "ImpactStatement": "By default users will have exposed webhooks.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat apps 5. Under Chat apps access settings, set Allow users to add and use incoming webhooks to OFF", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Chat and classic Hangouts 4. Select Chat apps 5. Under Chat apps access settings, verify Allow users to add and use incoming webhooks is OFF", + "AdditionalInformation": "", + "DefaultValue": "Allow users to add and use incoming webhooks is ON", + "References": "https://developers.google.com/chat/concepts/apps" + } + ] + }, + { + "Id": "3.1.6.1", + "Description": "Ensure accessing groups from outside this organization is set to private", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.6 Groups for Business", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Choose whether people outside your organization can access your groups. Group owners can further restrict access as needed.", + "RationaleStatement": "Who can externally view groups internal to the organization should be carefully controlled and their access vetted as needed.", + "ImpactStatement": "No one outside your organization can view or search for your groups. External users can email the group if group settings allow.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Sharing options 6. Set Accessing groups from outside this organization to Private 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Sharing options 6. Verify Accessing groups from outside this organization is Private", + "AdditionalInformation": "", + "DefaultValue": "Accessing groups from outside this organization is Private", + "References": "https://apps.google.com/supportwidget/articlehome?hl=en&article_url=https%3A %2F%2Fsupport.google.com%2Fa%2Fanswer%2F10308022%3Fhl%3Den&pro duct_context=10308022&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "3.1.6.2", + "Description": "Ensure creating groups is restricted", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.6 Groups for Business", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control who is allowed to create Groups in your organization and if they can have external members.", + "RationaleStatement": "The organization should have some control over the organizational groups created and the purpose they are for.", + "ImpactStatement": "In a large organization, this may cause too much burden on administrators.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Creating groups 6. Select Only organization admins can create groups 7. Set Group owners can allow external members Organization admins can always add external members to unchecked 8. Set Group owners can allow incoming email from outside the organization to unchecked 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Creating groups 6. Verify Only organization admins can create groups is selected 7. Verify Group owners can allow external members Organization admins can always add external members is unchecked 8. Verify Group owners can allow incoming email from outside the organization is unchecked", + "AdditionalInformation": "", + "DefaultValue": "• Anyone in the organization can create groups is selected • Group owners can allow external members Organization admins can always add external members is unchecked • Group owners can allow incoming email from outside the organization is unchecked", + "References": "" + } + ] + }, + { + "Id": "3.1.6.3", + "Description": "Ensure default for permission to view conversations is restricted", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.6 Groups for Business", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "By default, only allow group members to view group conversations.", + "RationaleStatement": "Conversation viewing can always be expanded by exception for certain groups as needed (Need to know), but by default be restricted.", + "ImpactStatement": "No practical impact, since Group members can view conversations in the Group.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Sharing options 6. Set Default for permission to view conversations to All group members 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Groups for Business 5. Select Sharing options 6. Verify Default for permission to view conversations is All group members", + "AdditionalInformation": "", + "DefaultValue": "Default for permission to view conversations is All organization users", + "References": "" + } + ] + }, + { + "Id": "3.1.7.1", + "Description": "Ensure service status for Google Sites is set to off", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.7 Sites", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "By default turn off Google Sites for all users.", + "RationaleStatement": "There is really no reason for every user within an organization to have access to Google Sites. If this capability is needed, it can be enabled and configured for those users and groups by exception as required by the organization to meet specific needs.", + "ImpactStatement": "Users will not be have access to Google Sites.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Sites 5. Select Service status 6. Set Service status to OFF for everyone 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select Sites 5. Select Service status 6. Verify Service status is OFF for everyone", + "AdditionalInformation": "", + "DefaultValue": "Service status is ON for everyone", + "References": "" + } + ] + }, + { + "Id": "3.1.8.1", + "Description": "Ensure access to external Google Groups is OFF for Everyone", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.8 Additional Google services", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Control whether users in your organization can access external groups from their Google Workspace account. External groups are created outside your organization and might include a public community group or a group for a club a user belongs to. Control access to external groups by turning on or off the Google Groups additional service — a legacy service in your Admin console that does only one thing: It allows or blocks users from accessing external groups from their Google Workspace account. NOTE: This service has no effect on your organization's internal groups.", + "RationaleStatement": "In general, most of the organization's personnel do not need to assess external groups. They can be allowed by exception as needed by the business.", + "ImpactStatement": "Users can't access external groups from their Google Workspace account. However, they do continue to receive email digests from groups they're already subscribed to when you turn off the service.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select `Additional Google services 5. Scroll down to Google Groups 6. Set it to OFF for everyone 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace 4. Select `Additional Google services 5. Scroll down to Google Groups 6. Verify it is OFF for everyone", + "AdditionalInformation": "", + "DefaultValue": "Google Groups is ON for Everyone", + "References": "" + } + ] + }, + { + "Id": "3.1.9.1.1", + "Description": "Ensure users access to Google Workspace Marketplace apps is restricted", + "Checks": [], + "Attributes": [ + { + "Section": "3 Apps", + "SubSection": "3.1.9 Google Workspace Marketplace", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict what Google Marketplace apps a user can install.", + "RationaleStatement": "Users should only be allowed to install approved and vetted apps. This will limit the overall attack surface for the organization.", + "ImpactStatement": "Users can only install approved Google Marketplace apps. This list will have to be created and maintained.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace Marketplace apps 4. Select Settings 5. Under Manage Google Workspace Marketplace allowlist access, set Settings to install third-party Google Workspace Marketplace apps: to Allow users to install and run only selected apps from the Marketplace 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Apps 3. Select Google Workspace Marketplace apps 4. Select Settings 5. Under Manage Google Workspace Marketplace allowlist access, verify Settings to install third-party Google Workspace Marketplace apps: is set to Allow users to install and run only selected apps from the Marketplace", + "AdditionalInformation": "", + "DefaultValue": "Settings to install third-party Google Workspace Marketplace apps: is Allow users to install and run any app from the Marketplace", + "References": "" + } + ] + }, + { + "Id": "4.1.1.1", + "Description": "Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enforce 2-Step Verification (Multi-Factor Authentication) for all users assigned administrative roles. These include roles such as: • Help Desk Admin • Groups Admin • Super Admin • Services Admin • User Management Admin • Mobile Admin • Android Admin • Custom Admin Roles", + "RationaleStatement": "Add an extra layer of security to users accounts by asking users to verify their identity when they enter a username and password. 2-Step Verification (Multi-factor authentication) requires an individual to present a minimum of two separate forms of authentication before access is granted. 2-Step Verification provides additional assurance that the individual attempting to gain access is who they claim to be. With 2- Step Verification, an attacker would need to compromise at least two different a", + "ImpactStatement": "Implementation of 2-Step Verification (multi-factor authentication) for all users in administrative roles will necessitate a change to user routine. All users in administrative roles will be required to enroll in 2-Step Verification using using phone, SMS, or an authentication application. After enrollment, use of 2-Step Verification will be required for future access to the environment.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Security and click on 2-Step Verification 3. Select the appropriate group with ALL ADMIN ROLES -- Create this group if needed 4. Under Authentication, set Allow users to turn on 2-Step Verification to checked 5. Set Enforcement to On 6. Set New user enrollment period is set to 2 weeks 7. Under Frequency, set Allow user to trust device to unchecked 8. Under Methods, set Any except verification codes via text, phone call to selected 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Security and click on 2-Step Verification 3. Select the appropriate group with ALL ADMIN ROLES -- Create this group if needed 4. Under Authentication, ensure Allow users to turn on 2-Step Verification is checked 5. Ensure Enforcement is set to On 6. Ensure New user enrollment period is set to 2 weeks 7. Under Frequency, ensure Allow user to trust device is unchecked 8. Under Methods, ensure Any except verification codes via text, phone call is selected", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to turn on 2-Step Verification is checked • Enforcement is Off • New user enrollment period is None • Frequency - Allow user to trust device is checked • Methods is Any", + "References": "" + } + ] + }, + { + "Id": "4.1.1.2", + "Description": "Ensure hardware security keys are used for all users in administrative roles and other high-value accounts", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "A hardware security key connects to a user's device using USB (A & C), Lightning, NFC, or Bluetooth connection. Also, many Android phones and Apple iPhones have built-in security keys accessible via Bluetooth and that can be assigned to a Google Workspace account. The purpose of a physical security key is to provide an additional security layer to high value accounts; in the event of a compromise of a user's credentials (username and password) without the associated security key, the authentication process cannot be successfully completed.", + "RationaleStatement": "The purpose of a physical security key is to provide an additional security layer to high value accounts; in the event of a compromise of a user's credentials (username and password) without the associated security key, the authentication process cannot be successfully completed. Hardware security keys help to protect high value accounts from targeted attacks, including phishing attempts. Adding a hardware security key requirement to your Google privileged accounts adds another layer of depth of", + "ImpactStatement": "Users with hardware security keys enabled will need to have physical access to the hardware key in order complete the authentication process and this will force users to adopt a practice of making sure that the physical key is available to them at any point in time that they need to be able to log in. If a hardware security key is lost or stolen, the impacted user can gain access to their Google account by using a backup MFA process and then remove the lost/stolen key and add another one. If a h", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Security and click on Authentication 3. Under Authentication, select 2-Step Verification 4. Select the option to Allow users to turn on 2-Step Verification 5. Under Enforcement, enable either 'On' or else 'On from' and configure a valid date 6. Under Methods, select Only security key to force the use of a security key 7. Under 2-Step Verification policy suspension grace period, select 1 day 8. Under Security codes, select Don't allow users to generate security codes 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Go to Security and click on Authentication 3. Under Authentication, select 2-Step Verification 4. Ensure the option to Allow users to turn on 2-Step Verification is checked 5. Ensure that the Enforcement option is set to either 'On' or 'On from' with a valid date present 6. Under Methods ensure that Only security key is selected 7. Under 2-Step Verification policy suspension grace period ensure that 1 day is selected 8. Under Security codes ensure that Don't allow users to generate security codes is selected", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to turn on 2-Step Verification is checked • Enforcement is Off • New user enrollment period is None • Frequency - Allow user to trust device is checked • Methods is Any", + "References": "https://support.google.com/accounts/answer/6103523?hl=En" + } + ] + }, + { + "Id": "4.1.1.3", + "Description": "Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enforce 2-Step Verification (Multi-Factor Authentication) for all users.", + "RationaleStatement": "Add an extra layer of security to users accounts by asking users to verify their identity when they enter a username and password. 2-Step Verification (Multi-factor authentication) requires an individual to present a minimum of two separate forms of authentication before access is granted. 2-Step Verification provides additional assurance that the individual attempting to gain access is who they claim to be. With 2- Step Verification, an attacker would need to compromise at least two different a", + "ImpactStatement": "Implementation of 2-Step Verification (multi-factor authentication) for all users will necessitate a change to user routine. All users will be required to enroll in 2-Step Verification using using phone, SMS, or an authentication application. After enrollment, use of 2-Step Verification will be required for future access to the environment.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select 2-Step Verification 4. Under Authentication, check - Allow users to turn on 2-Step Verification 5. Set Enforcement to On 6. Set New user enrollment period to 2 weeks 7. Under Frequency, uncheck - Allow user to trust device 8. Under Methods, select - Any except verification codes via text, phone call 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select 2-Step Verification 4. Under Authentication, ensure Allow users to turn on 2-Step Verification is checked 5. Ensure Enforcement is set to On 6. Ensure New user enrollment period is set to 2 weeks 7. Under Frequency, ensure Allow user to trust device is not checked 8. Under Methods, ensure Any except verification codes via text, phone call is selected", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to turn on 2-Step Verification is checked • Enforcement is Off • New user enrollment period is None • Frequency - Allow user to trust device is checked • Methods is Any", + "References": "" + } + ] + }, + { + "Id": "4.1.2.1", + "Description": "Ensure Super Admin account recovery is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "This option allows Super Admin users to recover access to their accounts if their password has been forgotten. The option is not available if either Single Sign On or Password Sync is in use.", + "RationaleStatement": "Allowing Super Admins to recover access to their accounts when they have forgotten their passwords reduces the number of support tickets generated by users, and reduces the amount of down time spent waiting on the account recovery process to initiate and complete.", + "ImpactStatement": "The potential impact to Super Admins being allowed to recover their accounts includes: 1. The Super Admins are now empowered to reset their passwords. 2. The Super Admins will no longer need to call a helpdesk or open a support ticket to regain access to their account. An organization that allows users to recover their account will realize less time spent by administrative staff working on these tasks.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security. 3. Select Authentication. 4. Under Account recovery select Super admin account recovery. 5. Set Allow super admins to recover their account to unchecked 6. Click Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security. 3. Select Authentication. 4. Under Account recovery select Super admin account recovery. 5. Ensure Allow super admins to recover their account is unchecked.", + "AdditionalInformation": "", + "DefaultValue": "Allow super admins to recover their account is OFF", + "References": "" + } + ] + }, + { + "Id": "4.1.2.2", + "Description": "Ensure User account recovery is enabled", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "This option allows non-Super Admin users to recover access to their accounts if their password has been forgotten. The option is not available if either Single Sign On or Password Sync is in use.", + "RationaleStatement": "Allowing users to recover access to their accounts when they have forgotten their passwords reduces the number of support tickets generated by users, and reduces the amount of down time spent waiting on the account recovery process to initiate and complete.", + "ImpactStatement": "The potential impact to users being allowed to recover their accounts includes: 1. The user is now empowered to reset their passwords. 2. The user will no longer need to call a helpdesk or open a support ticket to regain access to their account. An organization that allows users to recover their account will realize less time spent by administrative staff working on these tasks.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security. 3. Select User account recovery 4. Select either the pencil icon or the setting itself. 5. Set Allow users and non-super admins to recover their account to checked. 6. Select Save.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security. 3. Select User account recovery 4. Verify Allow users and non-super admins to recover their account is checked.", + "AdditionalInformation": "", + "DefaultValue": "Allow users and non-super admins to recover their account is OFF", + "References": "" + } + ] + }, + { + "Id": "4.1.3.1", + "Description": "Ensure Advanced Protection Program is configured", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Enable Google's Advanced Protection Platform for all users and prevent the use of security codes where applicable.", + "RationaleStatement": "Sophisticated phishing tactics can trick the most savvy users into giving their sign-in credentials to attackers. Advanced Protection requires you to use a security key, which is a hardware device or special software on your phone used to verify your identity, to sign in to your Google Account. Unauthorized users won't be able to sign in without your security key, even if they have your username and password. The Advanced Protection Program includes a curated group of high-security policies that are applied to enrolled accounts. Additional policies may be added to the Advanced Protection Program to ensure the protections are current. Advanced Protection allows you to apply all of these protections at once, and override similar settings you may have configured manually. These policies include: Strong authentication with security keys, Use of security codes with security keys (as needed), Restrictions on third-party access to account data, Deep Gmail scans, Google Safe Browsing protections in Chrome, and Account recovery through admin.", + "ImpactStatement": "User Impact • You need your security key when you sign in for the first time on a computer, browser, or device. If you stay signed in, you may not be asked to use your security key the next time you log in. • Limits third-party app access to your data, puts stronger checks on suspicious downloads, and tightens account recovery security to help prevent unauthorized access. Security Keys - 2 Required • Android: With an Android 7.0+ phone, you can enroll in a few", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Advanced Protection Program 4. Under Enrollment - Allow users to enroll in the Advanced Protection Program, set Enable user enrollment to selected for the desired organizational unit or group 5. Under Security Codes, set Do not allow users to generate security codes to selected for the desired organizational unit or group 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Advanced Protection Program 4. Under Enrollment - Allow users to enroll in the Advanced Protection Program, ensure Enable user enrollment is selected for the desired organizational unit or group 5. Under Security Codes, ensure Do not allow users to generate security codes is selected for the desired organizational unit or group", + "AdditionalInformation": "", + "DefaultValue": "• Allow users to enroll in the Advanced Protection Platform is selected • Security codes is Allow security codes without remote access", + "References": "" + } + ] + }, + { + "Id": "4.1.4.1", + "Description": "Ensure login challenges are enforced", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace to verify a user's identity post-sso.", + "RationaleStatement": "Many organizations use third-party identity providers (IdPs) to authenticate users who use single sign on (SSO) through SAML. The third-party IdP authenticates users and no additional risk-based challenges are presented to them. Any Google 2-Step Verification (2SV) configuration is ignored. This is the default behavior. You can set a policy to allow additional risk-based authentication challenges and 2SV if it’s configured. If Google receives a valid SAML assertion (authentication information ab", + "ImpactStatement": "The potential impact associated with implementation of this setting is dependent upon the existing 2-Step Verification (2SV) polices. • If you have existing 2SV policies, such as 2SV enforcement, those policies apply immediately. • Users affected by the new policy and who are enrolled in 2SV get a 2SV challenge at sign-in. • Based on Google sign-in risk analysis, users might see risk-based challenges at sign-in.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Login Challenges 4. Under Post-SSO verification, set Logins using SSO are subject to additional verifications (if appropriate) and 2-Step Verification (if configured) is checked 5. Select Save 6. Under Login challenges, set Use employee ID to keep my users more secure to unchecked 7. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Login Challenges 4. Under Post-SSO verification, ensure Logins using SSO are subject to additional verifications (if appropriate) and 2-Step Verification (if configured) is checked 5. Under Login challenges, ensure Use employee ID to keep my users more secure is unchecked", + "AdditionalInformation": "", + "DefaultValue": "• Post-SSO verification is Logins using SSO bypass additional verifications • Use employee ID to keep my users more secure is unchecked", + "References": "" + } + ] + }, + { + "Id": "4.1.5.1", + "Description": "Ensure password policy is configured for enhanced security", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.1 Authentication", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace Password Policy with a more secure length and is enforced upon next sign-in to protect against the use of common password attacks.", + "RationaleStatement": "Strong password policies protect an organization by prohibiting the use of weak passwords.", + "ImpactStatement": "The potential impact associated with implementation of this setting is dependent upon the existing password policies in place in the environment. For environments that have strong password policies in place, the impact will be minimal. For organizations that do not have strong password policies in place, enhancing the password policy may require users to change passwords, and adhere to more stringent requirements than they have been accustomed to. Configuring passwords to expire at a 1 year mark", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Password management 4. Under Strength, set Enforce strong passwords to checked 5. Under Length, set Minimum Length to 14 or greater 6. Under Strength and Length enforcement, set Enforce password policy at next sign-in is checked 7. Under Reuse, set Allow password reuse to unchecked 8. Under Expiration, set Password reset frequency to 365 Days 9. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Password management 4. Under Strength, ensure Enforce strong passwords is checked 5. Under Length, ensure Minimum Length is set to 14+ 6. Under Strength and Length enforcement, ensure Enforce password policy at next sign-in is set to checked 7. Under Reuse, ensure Allow password reuse is unchecked 8. Under Expiration, ensure Password reset frequency is set to 365 Days", + "AdditionalInformation": "", + "DefaultValue": "• Enforce strong password is checked • Minimum length is 8 • Maximum length is 100 • Enforce password policy at next sign-in is not checked • Allow password reuse is not checked • Expiration is Never expires", + "References": "" + } + ] + }, + { + "Id": "4.2.1.1", + "Description": "Ensure application access to Google services is restricted", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Prevent unrestricted application access to Google services.", + "RationaleStatement": "You can restrict (or leave unrestricted) access to most Workspace services, including Google Cloud Platform services such as Machine Learning. For Gmail and Google Drive, you can specifically restrict access to high-risk scopes (for example, sending Gmail or deleting files in Drive). While users are prompted to consent to apps, if an app uses restricted scopes and you haven’t specifically trusted it, users can’t add it.", + "ImpactStatement": "The potential impact associated with implementation of this setting is that any previously installed apps that you haven’t trusted stop working and tokens are revoked. When a user tries to install an app that has a restricted scope, they’re notified that it’s blocked.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Overview, select MANAGE GOOGLE SERVICES 6. Select ALL applicable Google Services 7. Click Change access 8. Select Restricted: Only trusted apps can access a service", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Overview, select MANAGE GOOGLE SERVICES 6. Ensure ALL applicable Google Services have Restricted in the Access column", + "AdditionalInformation": "", + "DefaultValue": "Access is Unrestricted", + "References": "" + } + ] + }, + { + "Id": "4.2.1.2", + "Description": "Review third-party applications periodically", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Weekly review connected applications for potential malicious or unintended access or connections.", + "RationaleStatement": "Performing a periodic review of connected applications and their permission scopes ensures only permitted and required applications can access organizational data or resources. Attackers commonly attempt to persuade or trick users to grant their application access to organizational data resources by asking for their consent.", + "ImpactStatement": "", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Overview, select MANAGE THIRD-PARTY APP ACCESS 6. Select Change Access for the application you wish to remove 7. Select Blocked: Can't access any Google service 8. Log in to the Google Cloud Platform - Resource Manager https://console.cloud.google.com/cloud-resource-manager as an administrator 9. Now Delete the desired application", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Overview, select MANAGE THIRD-PARTY APP ACCESS 6. Ensure all listed applications have been properly vetted and authorized by the appropriate personnel", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "4.2.1.3", + "Description": "Ensure internal apps can access Google Workspace APIs", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enable access to Google Workspace APIs for customer-owned / developed applications.", + "RationaleStatement": "All organization-built internal apps (owned by your organization), can be trusted to access restricted Google Workspace APIs. That way, the organization does not have to trust them all individually.", + "ImpactStatement": "", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Settings, select Trust internal, domain-owned apps 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls, then select App access control 5. Under Settings, verify Trust internal, domain-owned apps is selected", + "AdditionalInformation": "", + "DefaultValue": "Trust internal, domain-owned apps is selected", + "References": "" + } + ] + }, + { + "Id": "4.2.1.4", + "Description": "Review domain-wide delegation for applications periodically", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Weekly review domain-wide delegations for applications for potentially malicious or unintended access or connections.", + "RationaleStatement": "Domain-wide delegation is a powerful feature that allows apps to access users' data across your organization's entire Workspace account. Performing a periodic review of domain-wide delegations for applications and their permission scopes ensures only permitted and required applications can access organizational data or resources.", + "ImpactStatement": "", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls 5. Under Domain wide delegation, select MANAGE DOMAIN WIDE DELEGATION 6. Select Change Access for the application you wish to remove 7. Now Delete the desired application", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select API Controls 5. Under Domain wide delegation, select MANAGE DOMAIN WIDE DELEGATION 6. Ensure all listed applications have been properly vetted and authorized by the appropriate personnel", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "4.2.2.1", + "Description": "Ensure blocking access from unapproved geographic locations", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Restrict access to selected Google applications by geographic location.", + "RationaleStatement": "Restricting access to known/approved geographic locations is a simple way to limit where attacks can originate from. Especially for smaller organizations that do not need global access to applications.", + "ImpactStatement": "Valid/approved users traveling to a geographic region outside of those defined in the Access Level will not be able to access their applications.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: Create an appropriate Access Level 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Context-Aware Access 5. Select Access levels 6. Select Create Access Level 7. Under Details - Name the Access Level (Suggested using a clear name - ex. \"Restrict to USA\") 8. Under Conditions - Select Basic 9. Under Condition 1 - Select Meet attributes 10. Under Condition 1 - Select Add Attribute 11. Click on the Add Attribute drop-down box and select Geographic origin 12. Click on the far right drop-down box and select the region, or regions, to be allowed (ex. United States) 13. Click Save Assign the defined Access Level has been assigned to the application(s) that need the restriction 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Context-Aware Access 5. Select Assign access levels 6. For each", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: Verify an appropriate Access Level has been defined 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Context-Aware Access 5. Select Access levels 6. Review the list of Access Levels displayed and determine if there is an appropriate restriction on geographic access Verify the appropriate Access Level has been assigned to the application(s) that need the restriction 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Context-Aware Access 5. Select Assign access levels 6. Review the list of Google Applications displayed and make sure the appropriate access level for geographic access is assigned to each NOTE: CIS recommends geographically restricting access to the following Google applications at minimum: 1. Admin Console 2. Drives and Docs 3. Gmail 4. Google Vault", + "AdditionalInformation": "", + "DefaultValue": "None", + "References": "" + } + ] + }, + { + "Id": "4.2.3.1", + "Description": "Ensure DLP policies for Google Drive are configured", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Enabling Data Loss Prevention (DLP) policies for Google Drive allows organizations to control the content that users can share in Google Drive files outside the organization.", + "RationaleStatement": "Enabling DLP policies alerts users and administrators that specific types of data should not be exposed, helping to protect the data from accidental exposure. DLP gives you control over what users can share, and prevents unintended exposure of sensitive information such as credit card numbers or identity numbers", + "ImpactStatement": "Configuring a DLP policy for Google Drive will detect or block sensitive information.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Data protection 5. Select Manage Rules 6. Select ADD RULE, then select either New rule or New rule from template New rule Examples can be found here. 1. Set the rule Name 2. Optionally - Set the rule Description 3. Set the Scope as appropriate 4. Select Continue 5. Set Triggers by checking - File modified under Google Drive 6. Select ADD CONDITION and configure values (Field, Comparison Operator, Content to match) - Repeat as appropriate 7. Select Continue 8. Under Actions, select the desired action to take for each incident 9. Under Alerting, select the desired severity level 10. Under Alerting, Select - Send to alert center 11. Select Continue 12. Select Create New rule from template 1. Select the desired rule template 2. Optionally set the Name as desired 3. Optionally set the `Description as desire", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Data protection 5. Select Manage Rules 6. Ensure data protection rules exist and are enabled", + "AdditionalInformation": "", + "DefaultValue": "No DLP policies for Google Drive are configured by default", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F10846568%3Fvisit_id%3D63805868 5723082013- 4065283876&product_context=10846568&product_name=UnuFlow&trigger_cont ext=a https://workspaceupdates.googleblog.com/2020/10/data-protection-dlp- reports.html" + } + ] + }, + { + "Id": "4.2.4.1", + "Description": "Ensure Google session control is configured", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace's session control to strengthen session expiration.", + "RationaleStatement": "As an administrator, you can control how long users can access Google services, such as Gmail on the web, without having to sign in again. For example, for users that work remotely or from untrusted locations, you might want to limit the time that they can access sensitive resources by applying a shorter web session length. If users want to continue accessing a resource when a session ends, they’re prompted to sign in again and start a new session. How the settings work on mobile devices varies by device and app.", + "ImpactStatement": "The potential impact associated with implementation of this setting are: When a web session expires for a user, they see the Verify it's you page and must sign in again. When you change the session length, users need to sign out and in again for settings to take effect. If you set the session to never expire, users never have to sign in again. If you need some users to sign in more frequently than others, place them in different organizational units. Then, apply different session lengths to them. That way, certain users won't be interrupted to sign in when it isn't necessary. If a Google Meet meeting starts within 2 hours of a session's scheduled expiration, the user is forced to sign in again before the start of the meeting. This helps avoid an interruption to the meeting while in-progress. If you're using a third-party identity provider (IdP), such as Okta or Ping, and you set web session lengths for your users, you need to set the IdP session length parameter to expire before the Google session expires. That way, your users will be forced to sign in again. If the third-party IdP session is still valid when the Google session expires, the Google session might be renewed automatically without the user signing in again.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Google session control 5. Set Web session duration to 12 hours or less 6. Select Save", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Google session control 5. Verify Web session duration, is 12 hours or less", + "AdditionalInformation": "", + "DefaultValue": "Web session duration is 14 days", + "References": "" + } + ] + }, + { + "Id": "4.2.5.1", + "Description": "Ensure Google Cloud session control is configured", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Configure Google cloud session control to strengthen session expiration.", + "RationaleStatement": "As an administrator, you can control how long different users can access the Google Cloud console and Cloud SDK without having to re-authenticate. For example, you might want users with elevated privileges, like project owners, billing administrators, or others with administrator roles, to re-authenticate more frequently than regular users. If you set a session length, they’re prompted to sign in again to start a new session.", + "ImpactStatement": "The potential impact associated with implementation of this setting are: • When a Google cloud session expires for a user, they see the Verify it's you page and must sign in again. • If you require a security key, users who do not have one cannot use the GCP Console or Cloud SDK until they set it up. Once they have a security key, they can switch to using their password instead if they want. If you’re using a third-party identity provider (IdP): • With the GCP Console—If you require a user to re", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Google Cloud session control 5. Under Reauthentication policy, set Require reauthentication to selected and Exempt Trusted apps is unchecked 6. Set Reauthentication frequency to 16 hours (recommended) 7. Set Reauthentication method to Security key 8. Select Override", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Google Cloud session control 5. Under Reauthentication policy, ensure Require reauthentication is selected and Exempt Trusted apps is unchecked 6. Verify Reauthentication frequency, is16 hours (recommended) 7. Verify Reauthentication method is Security key", + "AdditionalInformation": "", + "DefaultValue": "Reauthentication policy is Never require reauthentication", + "References": "" + } + ] + }, + { + "Id": "4.2.6.1", + "Description": "Ensure less secure app access is disabled", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.2 Access and Data Control", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configure Google Workspace security settings to prevent access to less secure apps.", + "RationaleStatement": "You can block sign-in attempts from some apps or devices that are less secure. Apps that are less secure don't use modern security standards, such as OAuth. Using apps and devices that don’t use modern security standards increases the risk of accounts being compromised. Blocking these apps and devices helps keep your users and data safe.", + "ImpactStatement": "The potential impact associated with implementation of this setting is that users won't be able to turn on access to less secure apps. When you disable access to less secure apps while a less secure app has an open connection with a user account, the app will time out when it tries to refresh the connection. Timeout periods vary per app.", + "RemediationProcedure": "To configure this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Less secure apps 5. Select Disable access to less secure apps (Recommended) 6. Click Save to commit this configuration change.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator 2. Select Security 3. Select Access and Data Control 4. Select Less secure apps 5. Ensure Disable access to less secure apps (Recommended) is selected", + "AdditionalInformation": "", + "DefaultValue": "Disable access to less secure apps (Recommended) is selected", + "References": "" + } + ] + }, + { + "Id": "4.3.1", + "Description": "Ensure the Dashboard is reviewed regularly for anomalies", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.3 Security Center", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As an administrator, you can use the security dashboard to see an overview of different security reports. By default, each security report panel displays data from the last 7 days. You can customize the dashboard to view data from Today, Yesterday, This week, Last week, This month, Last month, or Days ago (up to 180 days). Charts/reports available (Minimum, but could be many more depending on account type): • DLP incidents • Top policy incidents • Failed device password attempts • Compromised device events • Suspicious device activities • OAuth scope grants by product (beta customers only) • OAuth grant activity • OAuth grants to new apps • User login attempts – Challenge method • User login attempts – Failed • User login attempts – Suspicious Details on what each of these charts/reports mean can be found here. This report should be reviewed weekly. NOTE: The availability of each individual report on the security dashboard depends on your Google Workspace edition. See Google documentation for more details. NOTE: In larger organizations reviewing this entire report weekly may not be possible. At a minimum, all Administrator and Super Administrator users should be reviewed, since they are a higher risk. These can be filtered from the overall user list.", + "RationaleStatement": "The Security report provides a comprehensive view of how people share and access data and whether they take appropriate security precautions. For example, you can review who installs external apps, shares numerous files, skips 2-Step Verification, and uses security keys.", + "ImpactStatement": "No user impact.", + "RemediationProcedure": "The remediation for any anomalies in the various fields varies widely (different sections of the Google Workspace Admin UI). Please refer to Google's documentation for specifics (here). NOTE: Many of these settings will be remedied by implementing other sections of this Benchmark. For example, an Admin not enrolled in 2-Step Verification can be remedied by implementing the Remediation procedure for the recommendation Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Reporting 3. Select Reports 4. Select User Reports 5. Select Security, and a table of results will be displayed with the fields listed in the Recommendation description above. 6. Review the displayed users and values for anomalies", + "AdditionalInformation": "", + "DefaultValue": "The report will display all users and fields.", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F7492330&assistant_id=generic- unu&product_context=7492330&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "4.3.2", + "Description": "Ensure the Security health is reviewed regularly for anomalies", + "Checks": [], + "Attributes": [ + { + "Section": "4 Security", + "SubSection": "4.3 Security Center", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As an administrator, the security health page enables you to monitor the configuration of your Admin console settings from one location. For example, you can check the status of settings like automatic email forwarding, device encryption, Drive sharing settings, and much more. Settings reported (Minimum, but could be many more depending on account type): • Blocking of compromised mobile devices • Mobile management • Mobile password requirements • Device encryption • Mobile inactivity reports • Auto account wipe • Application verification • Installation of mobile applications from unknown sources • External media storage • Two-step verification for users • Two-step verification for admins • Security key enforcement for admins Details on what each of these report entries mean can be found here. This report should be reviewed weekly. NOTE: The availability of each individual report on the security dashboard depends on your Google Workspace edition. See Google documentation for more details.", + "RationaleStatement": "The security health page provides visibility into your Admin console settings to help you better understand and manage security risks. If needed, you can make adjustments to your domain’s settings based on general security guidelines and best practices, while balancing these guidelines with your organization’s business needs and risk management policy.", + "ImpactStatement": "No user impact.", + "RemediationProcedure": "The remediation for any anomalies in the various settings varies widely (different sections of the Google Workspace Admin UI). Please refer to Google's documentation for specifics (here). NOTE: Many of these settings will be remedied by implementing other sections of this Benchmark. For example, an Admin not enrolled in 2-Step Verification can be remedied by implementing the Remediation procedure for the recommendation Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Security 3. Select Security center 4. Select Security health, and a table of results will be displayed with the settings listed in the Recommendation description above. 5. Review the displayed values for anomalies", + "AdditionalInformation": "", + "DefaultValue": "The report will display the status of a predefined group of settings based on your Google Workspace license.", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F7491656&assistant_id=generic- unu&product_context=7491656&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "5.1.1.1", + "Description": "Ensure the App Usage Report is reviewed regularly for anomalies", + "Checks": [], + "Attributes": [ + { + "Section": "5 Reporting", + "SubSection": "5.1 Reports", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As an administrator, you can use Apps usage reports to get an in-depth understanding of how your users use Google Workspace apps. Fields Available: • User • Gmail storage used (MB) • Drive storage used (MB) • Photos storage used (MB) • Total storage used (MB) • Storage used (%) • Classroom - last used time • Classes created • Posts created • Total emails • Emails sent • Emails received • Gmail (IMAP) - last used time • Gmail (POP) - last used time • Gmail (Web) - last used time • Files edited • Files viewed • Drive - last active time • Files added • Other types added • Google Docs added • Google Sheets added • Google Slides added • Google Forms added • Google Drawings added • Posts • +1s • +1s received • Comments • Comments received • Reshares • Reshares received • Search queries • Search queries from web • Search queries from Android • Search queries from iOS Details on what each of these fields mean can be found here. This report should be reviewed weekly. NOTE: In larger organizations reviewing this entire report weekly may not be possible. At a minimum, all Administrator and Super Administrator users should be reviewed, since they are a higher risk. These can be filtered from the overall user list.", + "RationaleStatement": "The App usage report can allow administrator to discover user that are potentially using application that they do not have access to and/or using in atypical ways.", + "ImpactStatement": "No user impact.", + "RemediationProcedure": "The remediation for any anomalies in the various fields varies widely (different sections of the Google Workspace Admin UI). Please refer to Google's documentation for specifics (here). NOTE: Many of these settings will be remedied by implementing other sections of this Benchmark. For example, an Admin showing recent Gmail (IMAP) - last used time and/or Gmail (POP) - last used time can be remedied by implementing the Remediation procedure for the recommendation Ensure POP and IMAP access is disabled for all users.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Reporting 3. Select Reports 4. Select User Reports 5. Select App usage, and a table of results will be displayed with the fields listed in the Recommendation description above. 6. Review the displayed users and values for anomalies", + "AdditionalInformation": "", + "DefaultValue": "The report will display all users and fields.", + "References": "https://apps.google.com/supportwidget/articlehome?hl=en&article_url=https%3A %2F%2Fsupport.google.com%2Fa%2Fanswer%2F4579578%3Fhl%3Den&assis tant_id=generic- unu&product_context=4579578&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "5.1.1.2", + "Description": "Ensure the Security Report is reviewed regularly for anomalies", + "Checks": [], + "Attributes": [ + { + "Section": "5 Reporting", + "SubSection": "5.1 Reports", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "As your organization's administrator, you can monitor your users' exposure to data compromise by reviewing the security report. Fields Available: • User • External apps • 2-Step verification enrollment • 2-Step verification enforcement • Password length compliance • Password strength • User account status • Admin status • Security keys enrolled • Less secure apps access • Gmail (IMAP) - last used time • Gmail (POP) - last used time • Gmail (Web) - last used time • External shares • Internal shares • Public • Anyone with link • Outside domain • Anyone in domain shares • Anyone in domain with link shares • Within domain shares • Private shares Details on what each of these fields mean can be found here. This report should be reviewed weekly. NOTE: In larger organizations reviewing this entire report weekly may not be possible. At a minimum, all Administrator and Super Administrator users should be reviewed, since they are a higher risk. These can be filtered from the overall user list.", + "RationaleStatement": "The Security report provides a comprehensive view of how people share and access data and whether they take appropriate security precautions. For example, you can review who installs external apps, shares numerous files, skips 2-Step Verification, and uses security keys.", + "ImpactStatement": "No user impact.", + "RemediationProcedure": "The remediation for any anomalies in the various fields varies widely (different sections of the Google Workspace Admin UI). Please refer to Google's documentation for specifics (here). NOTE: Many of these settings will be remedied by implementing other sections of this Benchmark. For example, an Admin not enrolled in 2-Step Verification can be remedied by implementing the Remediation procedure for the recommendation Ensure 2-Step Verification (Multi-Factor Authentication) is enforced for all users in administrative roles.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Reporting 3. Select Reports 4. Select User Reports 5. Select Security, and a table of results will be displayed with the fields listed in the Recommendation description above. 6. Review the displayed users and values for anomalies", + "AdditionalInformation": "", + "DefaultValue": "The report will display all users and fields.", + "References": "https://apps.google.com/supportwidget/articlehome?hl=en&article_url=https%3A %2F%2Fsupport.google.com%2Fa%2Fanswer%2F6000269%3Fhl%3Den&assis tant_id=generic- unu&product_context=6000269&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "6.1", + "Description": "Ensure User's password changed is configured", + "Checks": [], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.1", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when a user's password has changed.", + "RationaleStatement": "Ensuring that administrators are alerted when user passwords are changed provides organizations with the ability to detect and halt potential attacks involving credential compromise and account takeover.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User's password changed and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Medium 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the User's password changed shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User's password changed and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Medium 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "User's password changed is OFF", + "References": "" + } + ] + }, + { + "Id": "6.2", + "Description": "Ensure Government-backed attacks is configured", + "Checks": [], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.2", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google believes your users are being targeted by a government-backed attack.", + "RationaleStatement": "Ensuring that administrators are alerted that they may be being targeted by a government-backed entity allows them time to check their defenses and potentially up their sensitivity for anomalies. NOTE: Google sends these out of an abundance of caution — the notice does not necessarily mean that the account has been compromised or that there is a widespread attack. Rather, the notice reflects Goggle's assessment that a government-backed attacker has likely attempted to access the user’s account o", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Government-backed attacks and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to High 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Government-backed attacks shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Government-backed attacks and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to High 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Government-backed attacks is ON", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F3230421&assistant_id=generic- unu&product_context=3230421&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "6.3", + "Description": "Ensure User suspended due to suspicious activity is configured", + "Checks": [], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.3", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google suspended a user's account due to a potential compromise detected.", + "RationaleStatement": "Ensuring that administrators are alerted when the account was suspended by Google. The reason for this should be investigated ASAP, since it could be a possible indication of malicious activity. In any case, the user's account was suspended and something will need to be done to allow the user to resume work.", + "ImpactStatement": "Emails will be sent to all super administrators when triggered. Also, the user's account will be suspended and something will need to be done about that based on company policy (investigated, re-enabled, etc.).", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User suspended due to suspicious activity and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to High 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the User suspended due to suspicious activity shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User suspended due to suspicious activity and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to High 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "User suspended due to suspicious activity is ON", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F3230421&assistant_id=generic- unu&product_context=3230421&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "6.4", + "Description": "Ensure User granted Admin privilege is configured", + "Checks": [], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.4", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when a user has been granted an admin privilege.", + "RationaleStatement": "Ensuring that administrators are alerted when a user is given increased privileges could be an indication of compromise unless this access has been approved.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User granted Admin privilege and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Medium 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the User granted Admin privilege shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to User granted Admin privilege and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Medium 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "User granted Admin privilege is OFF", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F3230421&assistant_id=generic- unu&product_context=3230421&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "6.5", + "Description": "Ensure Suspicious programmatic login is configured", + "Checks": [], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.5", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google detects suspicious login attempts from applications or computer programs.", + "RationaleStatement": "Ensuring that administrators are alerted when suspicious login attempts occur. This could be an indication of an active attack on the company by an adversary using previously obtained credentials.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Suspicious programmatic login and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Low 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Suspicious programmatic login shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Suspicious programmatic login and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Low 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Suspicious programmatic login is ON", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F3230421&assistant_id=generic- unu&product_context=3230421&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "6.6", + "Description": "Ensure Suspicious login is configured", + "Checks": [], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.6", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google detects a sign-in attempt that doesn't match a user's normal behavior, such as a sign-in from an unusual location.", + "RationaleStatement": "Ensuring that administrators are alerted when suspicious login attempts occur. This could be an indication of an active attack on the company by an adversary using previously obtained credentials.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Suspicious login and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Low 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Suspicious login shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Suspicious login and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Low 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Suspicious login is ON", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F3230421&assistant_id=generic- unu&product_context=3230421&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "6.7", + "Description": "Ensure Leaked password is configured", + "Checks": [], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.7", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google detects compromised credentials requiring a reset of a user's password.", + "RationaleStatement": "Ensuring that administrators are alerted when Google detects that a user's credentials have been compromised due to a publicized breach. This is usually because the user has reused their credentials at another site that was breached.", + "ImpactStatement": "Emails will be sent to super administrators when triggered and in these cases, the user's password will need to be changed.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Leaked password and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to High 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Leaked password shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Leaked password and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Medium 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Leaked password is ON", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F3230421&assistant_id=generic- unu&product_context=3230421&product_name=UnuFlow&trigger_context=a" + } + ] + }, + { + "Id": "6.8", + "Description": "Ensure Gmail potential employee spoofing is configured", + "Checks": [], + "Attributes": [ + { + "Section": "6 Rules", + "SubSection": "6.8", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Configuring and enabling the setting that an alert will be generated when Google detects incoming messages are received where a sender’s name is in your Google Workspace directory, but the mail is not from your company’s domains or domain aliases.", + "RationaleStatement": "Ensuring that administrators are alerted when the email is being spoofed since this could be an indication of a phishing attempt.", + "ImpactStatement": "This setting should have no impact on the end user but will send emails to super administrators when triggered.", + "RemediationProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Gmail potential employee spoofing and select it. 5. Within the Actions pane, click the edit pencil on the right side of the pane. 6. Select Send to alert center (This will result in the alert being set to On). 7. Set the alert severity to Medium 8. To enable emails when this alert condition is met, select Send email notifications. Once enabled, the All super administrators option is selected by default. 9. Click Review to confirm the values. 10. Click Update Rule. 11. Confirm that the Gmail potential employee spoofing shows an Alert status of On in the list.", + "AuditProcedure": "To verify this setting via the Google Workspace Admin Console: 1. Log in to https://admin.google.com as an administrator. 2. Select Rules 3. Under Google protects you by default select View list. 4. Scroll to Gmail potential employee spoofing and select it. 5. Ensure that Alerts is set to On. 6. Ensure the Severity is set to Medium 7. Ensure that Email Notifications is set to On 8. Ensure that Email notification recipients is set to All super administrators", + "AdditionalInformation": "", + "DefaultValue": "Gmail potential employee spoofing is ON", + "References": "https://apps.google.com/supportwidget/articlehome?article_url=https%3A%2F%2 Fsupport.google.com%2Fa%2Fanswer%2F3230421&assistant_id=generic- unu&product_context=3230421&product_name=UnuFlow&trigger_context=a" + } + ] + } + ] +} diff --git a/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json new file mode 100644 index 0000000000..17c2b88b2c --- /dev/null +++ b/prowler/compliance/googleworkspace/cisa_scuba_0.6_googleworkspace.json @@ -0,0 +1,1740 @@ +{ + "Framework": "CISA-SCuBA", + "Name": "CISA Secure Cloud Business Applications (SCuBA) Google Workspace Baselines", + "Version": "0.6", + "Provider": "GoogleWorkspace", + "Description": "The CISA Secure Cloud Business Applications (SCuBA) project provides security configuration baselines for Google Workspace. These baselines define minimum security requirements using RFC 2119 requirement levels (SHALL/SHOULD/SHALL NOT) and include mappings to NIST SP 800-53 Rev 5 and MITRE ATT&CK.", + "Requirements": [ + { + "Id": "GWS.COMMONCONTROLS.1.1", + "Description": "Phishing-resistant MFA SHALL be required for all users", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "1 Phishing-Resistant MFA", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.1.2", + "Description": "If phishing-resistant MFA is not yet tenable, an MFA method from the list of acceptable MFA methods SHALL be used as an interim solution", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "1 Phishing-Resistant MFA", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.1.3", + "Description": "If phishing-resistant MFA is not yet tenable, SMS or Voice as the MFA method SHALL NOT be used", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "1 Phishing-Resistant MFA", + "Service": "commoncontrols", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.1.4", + "Description": "The 2SV enrollment period for new users SHALL be set to 1 day to 1 week", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "1 Phishing-Resistant MFA", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.1.5", + "Description": "Allow user to trust the device SHALL be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "1 Phishing-Resistant MFA", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.2.1", + "Description": "Context-Aware Access device-based policies SHOULD be implemented", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "2 Context-Aware Access", + "Service": "commoncontrols", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.3.1", + "Description": "Post-SSO verification for corporate SSO profile SHOULD be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "3 Login Challenges", + "Service": "commoncontrols", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.3.2", + "Description": "Post-SSO verification for other SSO profiles SHOULD be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "3 Login Challenges", + "Service": "commoncontrols", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.4.1", + "Description": "Google Workspace sessions SHALL re-authenticate after 12 hours", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "4 User Session Duration", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.5.1", + "Description": "Password strength SHALL be enforced", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "5 Secure Passwords", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.5.2", + "Description": "Minimum password length SHALL be at least 12 characters", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "5 Secure Passwords", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.5.3", + "Description": "Minimum password length SHOULD be at least 15 characters", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "5 Secure Passwords", + "Service": "commoncontrols", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.5.4", + "Description": "Password policy SHALL be enforced at next sign-in", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "5 Secure Passwords", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.5.5", + "Description": "Password reuse SHALL be restricted", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "5 Secure Passwords", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.5.6", + "Description": "Password expiration period SHALL NOT be set", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "5 Secure Passwords", + "Service": "commoncontrols", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.6.1", + "Description": "All admin accounts SHALL be cloud-only and not federated", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "6 Privileged Accounts", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.6.2", + "Description": "Between 2 and 8 super admin users SHALL be configured", + "Checks": [ + "directory_super_admin_count" + ], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "6 Privileged Accounts", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.7.1", + "Description": "Unmanaged conflicting accounts SHOULD be replaced with managed ones", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "7 Conflicting Account Management", + "Service": "commoncontrols", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.8.1", + "Description": "Account recovery for super admins SHALL be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "8 Account Recovery", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.8.2", + "Description": "Account recovery for non-super admin users SHALL be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "8 Account Recovery", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.8.3", + "Description": "Adding a recovery phone number and email address SHOULD be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "8 Account Recovery", + "Service": "commoncontrols", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.9.1", + "Description": "Privileged accounts SHALL be enrolled in the Advanced Protection Program", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "9 Advanced Protection Program", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.9.2", + "Description": "Sensitive user accounts SHOULD be enrolled in the Advanced Protection Program", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "9 Advanced Protection Program", + "Service": "commoncontrols", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.10.1", + "Description": "Unconfigured third-party apps SHALL be restricted from accessing GWS services", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "10 App Access to Google APIs", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.10.2", + "Description": "Third-party apps that do not meet minimum access requirements SHALL be blocked", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "10 App Access to Google APIs", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.10.3", + "Description": "Access to high-risk scopes SHALL be blocked unless explicitly trusted", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "10 App Access to Google APIs", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.10.4", + "Description": "OAuth apps with domain-wide delegation SHALL be reviewed periodically", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "10 App Access to Google APIs", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.10.5", + "Description": "Internal apps SHALL be allowed to access restricted Google Workspace APIs", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "10 App Access to Google APIs", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.11.1", + "Description": "Only approved Marketplace apps SHALL be allowed for installation", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "11 Authorized Marketplace Apps", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.12.1", + "Description": "Google Takeout SHALL be disabled for users", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "12 Google Takeout", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.13.1", + "Description": "All system-defined alerting rules SHALL be enabled with alerts sent to admin email addresses", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "13 System-Defined Rules", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.14.1", + "Description": "Google Workspace logs SHALL be sent to the organization's SIEM", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "14 Google Workspace Logs", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.14.2", + "Description": "Google Workspace logs SHALL be stored for a minimum of 6 months in active storage", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "14 Google Workspace Logs", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.15.1", + "Description": "Data SHALL be stored in the United States", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "15 Data Regions and Storage", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.15.2", + "Description": "Supplemental data storage SHALL be set to the United States", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "15 Data Regions and Storage", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.16.1", + "Description": "Non-essential additional Google services SHALL be disabled for all users", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "16 Additional Google Services", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.16.2", + "Description": "Essential additional Google services SHALL be enabled only for authorized users", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "16 Additional Google Services", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.17.1", + "Description": "Multiple super admins SHOULD be required to approve sensitive admin actions", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "17 Multi-Party Approval", + "Service": "commoncontrols", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.18.1", + "Description": "A DLP policy SHALL be configured for Drive", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "18 Data Loss Prevention", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.18.2", + "Description": "A DLP policy SHALL be configured for Gmail", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "18 Data Loss Prevention", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.18.3", + "Description": "A DLP policy SHALL be configured for Chat", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "18 Data Loss Prevention", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.COMMONCONTROLS.18.4", + "Description": "The DLP rule severity level SHALL be configured appropriately for the organization", + "Checks": [], + "Attributes": [ + { + "Section": "Common Controls", + "SubSection": "18 Data Loss Prevention", + "Service": "commoncontrols", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.1.1", + "Description": "Mail Delegation SHOULD be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "1 Mail Delegation", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.2.1", + "Description": "DKIM SHOULD be enabled for all domains", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "2 DomainKeys Identified Mail", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.3.1", + "Description": "An SPF policy SHALL be published for each domain that fails all non-approved senders", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "3 Sender Policy Framework", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.4.1", + "Description": "A DMARC policy SHALL be published at the full domain or the second-level domain for all Google Workspace domains, including user alias domains", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "4 Domain-based Message Authentication, Reporting, and Conformance", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.4.2", + "Description": "The DMARC message rejection option SHALL be p=reject", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "4 Domain-based Message Authentication, Reporting, and Conformance", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.4.3", + "Description": "The DMARC point of contact for aggregate reports SHALL include reports@dmarc.cyber.dhs.gov", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "4 Domain-based Message Authentication, Reporting, and Conformance", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.4.4", + "Description": "An agency point of contact SHOULD be included for aggregate and failure reports", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "4 Domain-based Message Authentication, Reporting, and Conformance", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.5.1", + "Description": "Protect against encrypted attachments from untrusted senders SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "5 Attachment Protections", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.5.2", + "Description": "Protect against attachments with scripts from untrusted senders SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "5 Attachment Protections", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.5.3", + "Description": "Protect against anomalous attachment types in emails SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "5 Attachment Protections", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.5.4", + "Description": "Google SHOULD be allowed to automatically apply future recommended settings for attachments", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "5 Attachment Protections", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.5.5", + "Description": "Emails flagged by SCuBA policies GWS.GMAIL.5.1 through GWS.GMAIL.5.3 SHALL NOT be kept in inbox", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "5 Attachment Protections", + "Service": "gmail", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.GMAIL.5.6", + "Description": "Any third-party or outside application selected for attachment protection SHOULD offer services comparable to those offered by Google Workspace (GWS)", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "5 Attachment Protections", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.6.1", + "Description": "Identify links behind shortened URLs SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "6 Links and External Images Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.6.2", + "Description": "Scan linked images SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "6 Links and External Images Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.6.3", + "Description": "Show warning prompt for any click on links to untrusted domains SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "6 Links and External Images Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.6.4", + "Description": "Google SHALL be allowed to automatically apply future recommended settings for links and external images", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "6 Links and External Images Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.6.5", + "Description": "Any third-party or outside application selected for links and external images protection SHOULD offer services comparable to those offered by Google Workspace (GWS)", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "6 Links and External Images Protection", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.7.1", + "Description": "Protect against domain spoofing based on similar domain names SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "7 Spoofing and Authentication Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.7.2", + "Description": "Protect against spoofing of employee names SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "7 Spoofing and Authentication Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.7.3", + "Description": "Protect against inbound emails spoofing your domain SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "7 Spoofing and Authentication Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.7.4", + "Description": "Protect against any unauthenticated emails SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "7 Spoofing and Authentication Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.7.5", + "Description": "Protect your Groups from inbound emails spoofing your domain SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "7 Spoofing and Authentication Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.7.6", + "Description": "Emails flagged by SCuBA policies GWS.GMAIL.7.1 through GWS.GMAIL.7.5 SHALL NOT be kept in inbox", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "7 Spoofing and Authentication Protection", + "Service": "gmail", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.GMAIL.7.7", + "Description": "Google SHALL be allowed to automatically apply future recommended settings for spoofing and authentication", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "7 Spoofing and Authentication Protection", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.7.8", + "Description": "Any third-party or outside application selected for spoofing and authentication protection SHOULD offer services comparable to those offered by Google Workspace", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "7 Spoofing and Authentication Protection", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.8.1", + "Description": "User email uploads SHALL be disabled to protect against unauthorized files being introduced into the secured environment", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "8 User Email Uploads", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.9.1", + "Description": "POP and IMAP access SHALL be disabled to protect sensitive agency or organization emails from being accessed through legacy applications or other third-party mail clients", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "9 POP and IMAP Access for Users", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.10.1", + "Description": "Google Workspace Sync SHOULD be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "10 Google Workspace Sync", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.11.1", + "Description": "Automatic forwarding SHOULD be disabled, especially to external domains", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "11 Automatic Forwarding", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.12.1", + "Description": "Using a per-user outbound gateway that is a mail server other than the Google Workspace (GWS) mail servers SHALL be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "12 Per-user Outbound Gateways", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.13.1", + "Description": "Unintended external reply warnings SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "13 Unintended External Reply Warning", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.14.1", + "Description": "An email allowlist SHOULD not be implemented", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "14 Email Allowlist", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.15.1", + "Description": "Enhanced pre-delivery message scanning SHALL be enabled to prevent phishing", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "15 Enhanced Pre-Delivery Message Scanning", + "Service": "gmail", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GMAIL.15.2", + "Description": "Any third-party or outside application selected for enhanced pre-delivery message scanning SHOULD offer services comparable to those offered by Google Workspace", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "15 Enhanced Pre-Delivery Message Scanning", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.16.1", + "Description": "Security sandbox SHOULD be enabled to provide additional protections for emails", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "16 Security Sandbox", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.16.2", + "Description": "Any third-party or outside application selected for security sandbox SHOULD offer services comparable to those offered by Google Workspace", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "16 Security Sandbox", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.17.1", + "Description": "Comprehensive mail storage SHOULD be enabled to allow information traceability across applications", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "17 Comprehensive Mail Storage", + "Service": "gmail", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GMAIL.18.1", + "Description": "Domains SHALL NOT be added to lists that bypass spam filters", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "18 Spam Filtering", + "Service": "gmail", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.GMAIL.18.2", + "Description": "Domains SHALL NOT be added to lists that bypass spam filters and hide warnings", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "18 Spam Filtering", + "Service": "gmail", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.GMAIL.18.3", + "Description": "Bypass spam filters and hide warnings for all messages from internal and external senders SHALL NOT be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Gmail", + "SubSection": "18 Spam Filtering", + "Service": "gmail", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.1", + "Description": "External sharing SHALL be restricted to allowlisted domains", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.2", + "Description": "Receiving files from outside of allowlisted domains SHOULD be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.3", + "Description": "Warnings SHALL be enabled when a user is attempting to share with someone not in allowlisted domains", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.4", + "Description": "If sharing outside of the organization, then agencies SHOULD disable sharing of files with individuals who are not using a Google account", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.5", + "Description": "Any OUs that do allow external sharing SHOULD disable making content available to anyone with the link", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.6", + "Description": "Agencies SHALL set access checking to recipients only", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.7", + "Description": "Users SHOULD NOT be allowed to upload or move content to shared drives owned by another organization", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHOULD NOT" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.8", + "Description": "Private to owner SHALL be the default access level for newly created items", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.9", + "Description": "Out-of-Domain file-level warnings SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.10", + "Description": "If external sharing is not allowed, then forms owned by users within the organization SHOULD NOT be able to accept responses from anyone with the link outside the organization", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHOULD NOT" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.1.11", + "Description": "If receiving external files is not allowed, then users in the organization SHOULD NOT be able to submit responses to forms from users or shared drives outside of the organization", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "1 Sharing Outside the Organization", + "Service": "drivedocs", + "Type": "SHOULD NOT" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.2.1", + "Description": "Agencies SHOULD NOT allow members with manager access to override shared Google Drive creation settings", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "2 Shared Drive Creation", + "Service": "drivedocs", + "Type": "SHOULD NOT" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.2.2", + "Description": "Agencies SHALL allow users who are not shared Google Drive members to be added to files", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "2 Shared Drive Creation", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.3.1", + "Description": "Agencies SHALL enable the security update for Google Drive files", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "3 Security Updates for Files", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.4.1", + "Description": "Agencies SHOULD disable Google Drive SDK access", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "4 Drive SDK", + "Service": "drivedocs", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.5.1", + "Description": "Agencies SHALL disable Google Drive Add-Ons", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "5 User Installation of Drive and Docs Add-Ons", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.DRIVEDOCS.6.1", + "Description": "Google Drive for Desktop SHALL be enabled only for authorized devices", + "Checks": [], + "Attributes": [ + { + "Section": "Drive and Docs", + "SubSection": "6 Drive for Desktop", + "Service": "drivedocs", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CALENDAR.1.1", + "Description": "External Sharing Options for Primary Calendars SHALL be configured to Only free/busy information (hide event details)", + "Checks": [], + "Attributes": [ + { + "Section": "Calendar", + "SubSection": "1 External Sharing Options", + "Service": "calendar", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CALENDAR.1.2", + "Description": "External sharing options for secondary calendars SHALL be configured to Only free/busy information (hide event details)", + "Checks": [], + "Attributes": [ + { + "Section": "Calendar", + "SubSection": "1 External Sharing Options", + "Service": "calendar", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CALENDAR.2.1", + "Description": "External invitations warnings SHALL be enabled to prompt users before sending invitations", + "Checks": [], + "Attributes": [ + { + "Section": "Calendar", + "SubSection": "2 External Invitations Warnings", + "Service": "calendar", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CALENDAR.3.1", + "Description": "Calendar Interop SHOULD be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Calendar", + "SubSection": "3 Calendar Interop Management", + "Service": "calendar", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.CALENDAR.3.2", + "Description": "Microsoft 365 (Graph API) SHALL be used in lieu of basic authentication to establish connectivity between tenants or organizations in cases where Calendar Interop is deemed necessary for agency mission fulfillment", + "Checks": [], + "Attributes": [ + { + "Section": "Calendar", + "SubSection": "3 Calendar Interop Management", + "Service": "calendar", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CALENDAR.4.1", + "Description": "Appointment Schedule with Payments SHALL be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Calendar", + "SubSection": "4 Paid Appointments", + "Service": "calendar", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CHAT.1.1", + "Description": "Chat history SHALL be enabled for information traceability", + "Checks": [], + "Attributes": [ + { + "Section": "Chat", + "SubSection": "1 Chat History", + "Service": "chat", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CHAT.1.2", + "Description": "Users SHALL NOT be allowed to change their history setting", + "Checks": [], + "Attributes": [ + { + "Section": "Chat", + "SubSection": "1 Chat History", + "Service": "chat", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.CHAT.2.1", + "Description": "External file sharing SHALL be disabled to protect sensitive information from unauthorized or accidental sharing", + "Checks": [], + "Attributes": [ + { + "Section": "Chat", + "SubSection": "2 External File Sharing", + "Service": "chat", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CHAT.3.1", + "Description": "Space history SHOULD be enabled for information traceability", + "Checks": [], + "Attributes": [ + { + "Section": "Chat", + "SubSection": "3 History for Spaces", + "Service": "chat", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.CHAT.4.1", + "Description": "External chat messaging SHALL be restricted to allowlisted domains only", + "Checks": [], + "Attributes": [ + { + "Section": "Chat", + "SubSection": "4 External Chat Messaging", + "Service": "chat", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CHAT.5.1", + "Description": "Chat content reporting SHALL be enabled for all conversation types", + "Checks": [], + "Attributes": [ + { + "Section": "Chat", + "SubSection": "5 Content Reporting", + "Service": "chat", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CHAT.5.2", + "Description": "All reporting message categories SHOULD be selected", + "Checks": [], + "Attributes": [ + { + "Section": "Chat", + "SubSection": "5 Content Reporting", + "Service": "chat", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.MEET.1.1", + "Description": "External users who were not explicitly invited SHALL be required to ask to join", + "Checks": [], + "Attributes": [ + { + "Section": "Meet", + "SubSection": "1 Meeting Access", + "Service": "meet", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.MEET.2.1", + "Description": "Meeting access SHALL be disabled for meetings created by users who are not members of any Google Workspace (GWS) tenant or organization", + "Checks": [], + "Attributes": [ + { + "Section": "Meet", + "SubSection": "2 Internal Access to External Meetings", + "Service": "meet", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.MEET.3.1", + "Description": "Host Management meeting features SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Meet", + "SubSection": "3 Host Management Meeting Features", + "Service": "meet", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.MEET.4.1", + "Description": "Warn for external participants SHALL be enabled", + "Checks": [], + "Attributes": [ + { + "Section": "Meet", + "SubSection": "4 External Participants", + "Service": "meet", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.MEET.5.1", + "Description": "Incoming calls SHALL be restricted to contacts and other users in the organization", + "Checks": [], + "Attributes": [ + { + "Section": "Meet", + "SubSection": "5 Incoming Calls", + "Service": "meet", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.MEET.6.1", + "Description": "Automatic recordings for Google Meet SHALL be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Meet", + "SubSection": "6 Video Meeting Settings", + "Service": "meet", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.MEET.6.2", + "Description": "Automatic transcripts for Google Meet SHALL be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Meet", + "SubSection": "6 Video Meeting Settings", + "Service": "meet", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GROUPS.1.1", + "Description": "Group access from outside the organization SHALL be disabled unless explicitly granted by the group owner", + "Checks": [], + "Attributes": [ + { + "Section": "Groups", + "SubSection": "1 External Group Access", + "Service": "groups", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.GROUPS.1.2", + "Description": "Group owners' ability to add external members to groups SHOULD be disabled unless necessary for agency mission fulfillment", + "Checks": [], + "Attributes": [ + { + "Section": "Groups", + "SubSection": "1 External Group Access", + "Service": "groups", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GROUPS.1.3", + "Description": "Group owners' ability to allow posting to a group by an external, non-group member SHOULD be disabled unless necessary for agency mission fulfillment", + "Checks": [], + "Attributes": [ + { + "Section": "Groups", + "SubSection": "1 External Group Access", + "Service": "groups", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GROUPS.2.1", + "Description": "Group creation SHOULD be restricted to admins within the organization unless necessary for agency mission fulfillment", + "Checks": [], + "Attributes": [ + { + "Section": "Groups", + "SubSection": "2 Group Creation", + "Service": "groups", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GROUPS.3.1", + "Description": "The default permission to view conversations SHOULD be set to All Group Members", + "Checks": [], + "Attributes": [ + { + "Section": "Groups", + "SubSection": "3 Default Permissions for Viewing Conversations", + "Service": "groups", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.GROUPS.4.1", + "Description": "The Ability for Groups to be Hidden from the Directory SHALL be disabled", + "Checks": [], + "Attributes": [ + { + "Section": "Groups", + "SubSection": "4 Ability to Hide Groups from the Directory", + "Service": "groups", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.SITES.1.1", + "Description": "Sites Service SHOULD be disabled for all users", + "Checks": [], + "Attributes": [ + { + "Section": "Sites", + "SubSection": "1 Sites Service Status", + "Service": "sites", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.CLASSROOM.1.1", + "Description": "Who can join classes in your domain SHALL be restricted to users in your domain or allowlisted domains", + "Checks": [], + "Attributes": [ + { + "Section": "Classroom", + "SubSection": "1 Class Membership", + "Service": "classroom", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CLASSROOM.1.2", + "Description": "Which classes users in your domain can join SHALL be restricted to classes in your domain or allowlisted domains", + "Checks": [], + "Attributes": [ + { + "Section": "Classroom", + "SubSection": "1 Class Membership", + "Service": "classroom", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CLASSROOM.2.1", + "Description": "Users SHALL NOT be able to authorize apps to access their Google Classroom data", + "Checks": [], + "Attributes": [ + { + "Section": "Classroom", + "SubSection": "2 Classroom API", + "Service": "classroom", + "Type": "SHALL NOT" + } + ] + }, + { + "Id": "GWS.CLASSROOM.3.1", + "Description": "Roster import with Clever SHOULD be turned off", + "Checks": [], + "Attributes": [ + { + "Section": "Classroom", + "SubSection": "3 Roster Import", + "Service": "classroom", + "Type": "SHOULD" + } + ] + }, + { + "Id": "GWS.CLASSROOM.4.1", + "Description": "Only teachers SHALL be allowed to unenroll students from classes", + "Checks": [], + "Attributes": [ + { + "Section": "Classroom", + "SubSection": "4 Student Unenrollment", + "Service": "classroom", + "Type": "SHALL" + } + ] + }, + { + "Id": "GWS.CLASSROOM.5.1", + "Description": "Class creation SHALL be restricted to verified teachers only", + "Checks": [], + "Attributes": [ + { + "Section": "Classroom", + "SubSection": "5 Class Creation", + "Service": "classroom", + "Type": "SHALL" + } + ] + } + ] +} diff --git a/prowler/compliance/m365/cis_4.0_m365.json b/prowler/compliance/m365/cis_4.0_m365.json index 5af364c93e..23582fbaac 100644 --- a/prowler/compliance/m365/cis_4.0_m365.json +++ b/prowler/compliance/m365/cis_4.0_m365.json @@ -32,6 +32,7 @@ "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": [ @@ -1214,7 +1215,8 @@ "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" + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_break_glass_account_fido2_security_key_registered" ], "Attributes": [ { diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index 5e61be11e0..f13c323df4 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -32,6 +32,7 @@ "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. Ensure two Emergency Access accounts have been defined.", "Checks": [ + "entra_break_glass_account_fido2_security_key_registered", "entra_emergency_access_exclusion" ], "Attributes": [ @@ -1446,7 +1447,8 @@ "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. Ensure administrator roles are using a CA policy with Phishing-resistant MFA strength.", "Checks": [ - "entra_admin_users_phishing_resistant_mfa_enabled" + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_break_glass_account_fido2_security_key_registered" ], "Attributes": [ { @@ -1582,7 +1584,7 @@ "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. Ensure sign-in frequency for Intune Enrollment is set to 'Every time'.", "Checks": [ - "entra_admin_users_sign_in_frequency_enabled" + "entra_intune_enrollment_sign_in_frequency_every_time" ], "Attributes": [ { @@ -1604,7 +1606,9 @@ { "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. Ensure the device code sign-in flow is blocked.", - "Checks": [], + "Checks": [ + "entra_conditional_access_policy_device_code_flow_blocked" + ], "Attributes": [ { "Section": "5 Microsoft Entra admin center", diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index cf24622373..ec97543ade 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -20,6 +20,7 @@ "Checks": [ "defender_antiphishing_policy_configured", "defender_antispam_policy_inbound_no_allowed_domains", + "entra_conditional_access_policy_block_o365_elevated_insider_risk", "entra_identity_protection_sign_in_risk_enabled", "entra_identity_protection_user_risk_enabled" ] @@ -120,6 +121,8 @@ "defenderxdr_endpoint_privileged_user_exposed_credentials", "defender_identity_health_issues_no_open", "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_conditional_access_policy_block_elevated_insider_risk", + "entra_conditional_access_policy_block_o365_elevated_insider_risk", "entra_identity_protection_sign_in_risk_enabled", "entra_identity_protection_user_risk_enabled" ] @@ -203,6 +206,7 @@ "admincenter_users_admins_reduced_license_footprint", "entra_admin_portals_access_restriction", "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_conditional_access_policy_block_o365_elevated_insider_risk", "entra_policy_guest_users_access_restrictions", "entra_seamless_sso_disabled" ] @@ -240,9 +244,13 @@ "defenderxdr_endpoint_privileged_user_exposed_credentials", "entra_admin_users_mfa_enabled", "entra_admin_users_sign_in_frequency_enabled", + "entra_break_glass_account_fido2_security_key_registered", "entra_default_app_management_policy_enabled", "entra_all_apps_conditional_access_coverage", "entra_conditional_access_policy_unknown_device_blocked", + "entra_conditional_access_policy_device_registration_mfa_required", + "entra_intune_enrollment_sign_in_frequency_every_time", + "entra_conditional_access_policy_device_code_flow_blocked", "entra_legacy_authentication_blocked", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", @@ -464,6 +472,7 @@ "defender_malware_policy_common_attachments_filter_enabled", "defender_malware_policy_comprehensive_attachments_filter_applied", "defender_malware_policy_notifications_internal_users_malware_enabled", + "entra_conditional_access_policy_device_code_flow_blocked", "entra_identity_protection_sign_in_risk_enabled", "entra_identity_protection_user_risk_enabled", "entra_legacy_authentication_blocked", @@ -616,8 +625,11 @@ "Checks": [ "defenderxdr_endpoint_privileged_user_exposed_credentials", "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_conditional_access_policy_approved_client_app_required_for_mobile", "entra_conditional_access_policy_app_enforced_restrictions", "entra_conditional_access_policy_unknown_device_blocked", + "entra_conditional_access_policy_device_registration_mfa_required", + "entra_intune_enrollment_sign_in_frequency_every_time", "entra_managed_device_required_for_authentication", "entra_managed_device_required_for_mfa_registration", "entra_users_mfa_capable", @@ -651,6 +663,7 @@ "entra_admin_users_mfa_enabled", "entra_admin_users_phishing_resistant_mfa_enabled", "entra_admin_users_sign_in_frequency_enabled", + "entra_break_glass_account_fido2_security_key_registered", "entra_app_registration_no_unused_privileged_permissions", "entra_policy_ensure_default_user_cannot_create_tenants", "entra_policy_guest_invite_only_for_admin_roles", @@ -671,8 +684,11 @@ ], "Checks": [ "entra_admin_portals_access_restriction", + "entra_conditional_access_policy_approved_client_app_required_for_mobile", "entra_conditional_access_policy_app_enforced_restrictions", "entra_conditional_access_policy_unknown_device_blocked", + "entra_conditional_access_policy_block_elevated_insider_risk", + "entra_conditional_access_policy_block_o365_elevated_insider_risk", "entra_policy_guest_users_access_restrictions", "sharepoint_external_sharing_restricted" ] @@ -694,6 +710,11 @@ "entra_admin_users_sign_in_frequency_enabled", "entra_all_apps_conditional_access_coverage", "entra_conditional_access_policy_unknown_device_blocked", + "entra_conditional_access_policy_device_registration_mfa_required", + "entra_intune_enrollment_sign_in_frequency_every_time", + "entra_break_glass_account_fido2_security_key_registered", + "entra_conditional_access_policy_approved_client_app_required_for_mobile", + "entra_conditional_access_policy_device_code_flow_blocked", "entra_identity_protection_sign_in_risk_enabled", "entra_managed_device_required_for_authentication", "entra_seamless_sso_disabled", @@ -759,7 +780,9 @@ "defender_antiphishing_policy_configured", "defender_safelinks_policy_enabled", "entra_admin_users_phishing_resistant_mfa_enabled", - "entra_conditional_access_policy_app_enforced_restrictions" + "entra_conditional_access_policy_app_enforced_restrictions", + "entra_conditional_access_policy_block_elevated_insider_risk", + "entra_conditional_access_policy_block_o365_elevated_insider_risk" ] }, { diff --git a/prowler/compliance/m365/prowler_threatscore_m365.json b/prowler/compliance/m365/prowler_threatscore_m365.json index ce12046d65..69f7f47502 100644 --- a/prowler/compliance/m365/prowler_threatscore_m365.json +++ b/prowler/compliance/m365/prowler_threatscore_m365.json @@ -27,7 +27,8 @@ "Id": "1.1.2", "Description": "Ensure multifactor authentication is enabled for all users in administrative roles", "Checks": [ - "entra_admin_users_mfa_enabled" + "entra_admin_users_mfa_enabled", + "entra_break_glass_account_fido2_security_key_registered" ], "Attributes": [ { @@ -81,7 +82,8 @@ "Id": "1.1.5", "Description": "Ensure 'Phishing-resistant MFA strength' is required for Administrators", "Checks": [ - "entra_admin_users_phishing_resistant_mfa_enabled" + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_break_glass_account_fido2_security_key_registered" ], "Attributes": [ { diff --git a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json index 080aa60476..ceca68d124 100644 --- a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json +++ b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json @@ -662,8 +662,8 @@ "Description": "It is recommended to setup an Event Rule and Notification that gets triggered when Identity Provider Group Mappings are created, updated or deleted. Event Rules are compartment scoped and will detect events in child compartments. It is recommended to create the Event rule at the root compartment level.", "RationaleStatement": "IAM Policies govern access to all resources within an OCI Tenancy. IAM Policies use OCI Groups for assigning the privileges. Identity Provider Groups could be mapped to OCI Groups to assign privileges to federated users in OCI. Monitoring and alerting on changes to Identity Provider Group mappings will help in identifying changes to the security posture.", "ImpactStatement": "There is no performance impact when enabling the above described features but depending on the amount of notifications sent per month there may be a cost associated.", - "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Identity` in the Service Name Drop-down and selecting `Idp Group Mapping – Create`, `Idp Group Mapping – Delete` and `Idp Group Mapping – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.identitycontrolplane.addidpgroupmapping\\,\\com.oraclecloud.identitycontrolplane.removeidpgroupmapping\\,\\com.oraclecloud.identitycontrolplane.updateidpgroupmapping\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", - "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `Idp Group Mapping` Changes (if any)4. Ensure the `Rule` is `ACTIVE`5. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Identity` and Event Types: `Idp Group Mapping – Create`, `Idp Group Mapping – Delete` and `Idp Group Mapping – Update`6. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:** 1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.identitycontrolplane.addidpgroupmappingcom.oraclecloud.identitycontrolplane.removeidpgroupmappingcom.oraclecloud.identitycontrolplane.updateidpgroupmapping```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", + "RemediationProcedure": "**From Console:**1. Go to the `Events Service` page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `compartment` that should host the rule3. Click `Create Rule`4. Provide a `Display Name` and `Description`5. Create a Rule Condition by selecting `Identity` in the Service Name Drop-down and selecting `Idp Group Mapping – Add`, `Idp Group Mapping – Remove` and `Idp Group Mapping – Update`6. In the `Actions` section select `Notifications` as Action Type7. Select the `Compartment` that hosts the Topic to be used.8. Select the `Topic` to be used9. Optionally add Tags to the Rule10. Click `Create Rule`**From CLI:**1. Find the `topic-id` of the topic the Event Rule should use for sending notifications by using the topic `name` and `Compartment OCID````oci ons topic list --compartment-id --all --query data [?name==''].{name:name,topic_id:\\topic-id\\} --output table```2. Create a JSON file to be used when creating the Event Rule. Replace topic id, display name, description and compartment OCID.```{ actions: { actions: [ { actionType: ONS, isEnabled: true, topicId: }] }, condition:{\\eventType\\:[\\com.oraclecloud.identitycontrolplane.addidpgroupmapping\\,\\com.oraclecloud.identitycontrolplane.removeidpgroupmapping\\,\\com.oraclecloud.identitycontrolplane.updateidpgroupmapping\\],\\data\\:{}}, displayName: , description: , isEnabled: true, compartmentId: }```3. Create the actual event rule```oci events rule create --from-json file://event_rule.json```4. Note in the JSON returned that it lists the parameters specified in the JSON file provided and that there is an OCID provided for the Event Rule", + "AuditProcedure": "**From Console:**1. Go to the Events Service page: [https://cloud.oracle.com/events/rules](https://cloud.oracle.com/events/rules)2. Select the `Compartment` that hosts the rules3. Find and click the `Rule` that handles `Idp Group Mapping` Changes (if any)4. Ensure the `Rule` is `ACTIVE`5. Click the `Edit Rule` button and verify that the `RuleConditions` section contains a condition for the Service `Identity` and Event Types: `Idp Group Mapping – Add`, `Idp Group Mapping – Remove` and `Idp Group Mapping – Update`6. Verify that in the `Actions` section the Action Type contains: `Notifications` and that a valid `Topic` is referenced.**From CLI:** 1. Find the OCID of the specific Event Rule based on Display Name and Compartment OCID```oci events rule list --compartment-id --query data [?\\display-name\\==''].{id:id} --output table```2. List the details of a specific Event Rule based on the OCID of the rule.```oci events rule get --rule-id ```3. In the JSON output locate the Conditions key value pair and verify that the following Conditions are present:```com.oraclecloud.identitycontrolplane.addidpgroupmappingcom.oraclecloud.identitycontrolplane.removeidpgroupmappingcom.oraclecloud.identitycontrolplane.updateidpgroupmapping```4. Verify the value of the `is-enabled` attribute is `true`5. In the JSON output verify that `actionType` is `ONS` and locate the `topic-id`6. Verify the correct topic is used by checking the topic name```oci ons topic get --topic-id --query data.{name:name} --output table```", "AdditionalInformation": "'- The same Notification topic can be reused by many Event Rules.- The generated notification will include an eventID that can be used when querying the Audit Logs in case further investigation is required.", "References": "" } diff --git a/prowler/compliance/oraclecloud/secnumcloud_3.2_oraclecloud.json b/prowler/compliance/oraclecloud/secnumcloud_3.2_oraclecloud.json new file mode 100644 index 0000000000..d3c52f21ba --- /dev/null +++ b/prowler/compliance/oraclecloud/secnumcloud_3.2_oraclecloud.json @@ -0,0 +1,1433 @@ +{ + "Framework": "SecNumCloud", + "Name": "SecNumCloud Referentiel d'Exigences v3.2", + "Version": "3.2", + "Provider": "OracleCloud", + "Description": "The SecNumCloud framework is published by ANSSI (Agence Nationale de la Securite des Systemes d'Information) to qualify cloud service providers operating in France. Version 3.2, dated March 8, 2022, covers IaaS, CaaS, PaaS, and SaaS services with requirements spanning information security policies, access control, cryptography, physical security, operational security, communications security, and data sovereignty protections against extra-European law.", + "Requirements": [ + { + "Id": "5.1", + "Description": "Le prestataire doit definir et appliquer des principes de securite de l'information adaptes a ses activites de fourniture de services cloud.", + "Name": "Principes", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit operer la prestation a l'etat de l'art pour le type d'activite retenu : utiliser des logiciels stables beneficiant d'un suivi des correctifs de securite et parametres de facon a obtenir un niveau de securite optimal. b) Le prestataire doit appliquer le guide d'hygiene informatique de l'ANSSI [HYGIENE], niveau renforce, au systeme d'information du service." + } + ], + "Checks": [] + }, + { + "Id": "5.2", + "Description": "Le prestataire doit definir, faire approuver par la direction, publier et communiquer aux salaries et aux tiers concernes un ensemble de politiques de securite de l'information.", + "Name": "Politique de securite de l'information", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de securite de l'information relative au service. b) La politique de securite de l'information doit identifier les engagements du prestataire quant au respect de la legislation et reglementation nationale en vigueur selon la nature des informations qui pourraient etre confiees par le commanditaire au prestataire ; il revient en revanche in fine au commanditaire de s'assurer du respect des contraintes legales et reglementaires applicables aux donnees qu'il confie effectivement au prestataire. c) La politique de securite de l'information doit notamment couvrir les themes abordes aux chapitres 6 a 19 du present referentiel. d) La direction du prestataire doit approuver formellement la politique de securite de l'information. e) Le prestataire doit reviser annuellement la politique de securite de l'information et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "5.3", + "Description": "Le prestataire doit definir et appliquer un processus d'appreciation des risques de securite de l'information.", + "Name": "Appreciation des risques", + "Attributes": [ + { + "Section": "5. Politiques de securite de l'information et gestion du risque", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter une appreciation des risques couvrant l'ensemble du perimetre du service. b) Le prestataire doit realiser son appreciation de risques en utilisant une methode documentee garantissant la reproductibilite et comparabilite de la demarche. c) Le prestataire doit prendre en compte dans l'appreciation des risques : la gestion d'informations du commanditaire ayant des besoins de securite differents ; les risques ayant des impacts sur les droits et libertes des personnes concernees en cas d'acces non autorise, de modification non desiree et de disparition de donnees a caractere personnel ; les risques de defaillance des mecanismes de cloisonnement des ressources de l'infrastructure technique (memoire, calcul, stockage, reseau) partagees entre les commanditaires ; les risques lies a l'effacement incomplet ou non securise des donnees stockees sur les espaces de memoire ou de stockage partages entre commanditaires, en particulier lors des reallocations des espaces de memoire et de stockage ; les risques lies a l'exposition des interfaces d'administration sur un reseau public ; les risques d'atteinte a la confidentialite des donnees des commanditaires par des tiers impliques dans la fourniture du service (fournisseurs, sous-traitants, etc.) ; les risques lies aux evenements naturels et sinistres physiques ; les risques lies a la separation des taches (voir 6.2.a) ; les risques lies aux environnements de developpement (voir 14.4.b). d) Le prestataire doit lister, dans un document specifique, les risques residuels lies a l'existence de lois extra-europeennes ayant pour objectif la collecte de donnees ou metadonnees des commanditaires sans leur consentement prealable. e) Le prestataire doit mettre a la disposition du commanditaire, sur demande de celui-ci, les elements d'appreciation des risques lies a la soumission des donnees du commanditaire au droit d'un etat non-membre de l'Union Europeenne. f) Lorsqu'il existe des exigences legales, reglementaires ou sectorielles specifiques liees aux types d'informations confiees par le commanditaire au prestataire, ce dernier doit les prendre en compte dans son appreciation des risques en s'assurant de respecter l'ensemble des exigences du present referentiel d'une part et de ne pas abaisser le niveau de securite etabli par le respect des exigences du present referentiel d'autre part. g) La direction du prestataire doit accepter formellement les risques residuels identifies dans l'appreciation des risques. h) Le prestataire doit reviser annuellement l'appreciation des risques et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "6.1", + "Description": "Le prestataire doit definir et attribuer toutes les responsabilites en matiere de securite de l'information.", + "Name": "Fonctions et responsabilites liees a la securite de l'information", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une organisation interne de la securite pour assurer la definition, la mise en place et le suivi du fonctionnement operationnel de la securite de l'information au sein de son organisation. b) Le prestataire doit designer un responsable de la securite des systemes d'information et un responsable de la securite physique. c) Le prestataire doit definir et attribuer les responsabilites en matiere de securite de l'information pour le personnel implique dans la fourniture du service. d) Le prestataire doit s'assurer apres tout changement majeur pouvant avoir un impact sur le service que l'attribution des responsabilites en matiere de securite de l'information est toujours pertinente. e) Le prestataire doit definir et attribuer les responsabilites en matiere de protection de donnees a caractere personnel, en coherence avec son role dans les traitements de donnees a caractere personnel (responsable de traitement, sous-traitant ou co-responsable). f) Le prestataire doit, lorsqu'il traite un grand nombre de donnees parmi lesquelles figurent des categories particulieres de donnees a caractere personnel telles que definies dans [RGPD], designer un delegue a la protection des donnees. g) Il est recommande que le prestataire, quel que soit le volume de donnees a caractere personnel qu'il traite, designe un delegue a la protection des donnees. h) Le prestataire doit realiser ou contribuer a la realisation d'une analyse d'impact relative a la protection des donnees a caractere personnel lorsque le traitement est susceptible d'engendrer un risque eleve pour les droits et libertes des personnes concernees (traitement de categories particulieres de donnees a caractere personnel telles que definies dans [RGPD], traitement de donnees a grande echelle, etc.). Cette analyse doit comporter une evaluation juridique du respect des principes et droits fondamentaux, ainsi qu'une etude plus technique des mesures techniques mises en oeuvre pour proteger les personnes des risques pour leur vie privee." + } + ], + "Checks": [] + }, + { + "Id": "6.2", + "Description": "Le prestataire doit separer les taches et les domaines de responsabilite incompatibles afin de reduire les possibilites de modification non autorisee ou de mauvais usage des actifs.", + "Name": "Separation des taches", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les risques associes a des cumuls de responsabilites ou de taches, les prendre en compte dans l'appreciation des risques et mettre en oeuvre des mesures de reduction de ces risques." + } + ], + "Checks": [] + }, + { + "Id": "6.3", + "Description": "Le prestataire doit etablir et maintenir des relations appropriees avec les autorites competentes.", + "Name": "Relations avec les autorites", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire mette en place des relations appropriees avec les autorites competentes en matiere de securite de l'information et de donnees a caractere personnel et, le cas echeant, avec les autorites sectorielles selon la nature des informations confiees par le commanditaire au prestataire." + } + ], + "Checks": [] + }, + { + "Id": "6.4", + "Description": "Le prestataire doit etablir et maintenir des relations appropriees avec des groupes de travail specialises, des associations professionnelles ou des forums traitant de la securite.", + "Name": "Relations avec les groupes de travail specialises", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire entretienne des contacts appropries avec des groupes de specialistes ou des sources reconnues, notamment pour prendre en compte de nouvelles menaces et les mesures de securite appropriees pour les contrer." + } + ], + "Checks": [] + }, + { + "Id": "6.5", + "Description": "Le prestataire doit integrer la securite de l'information dans la gestion de projet, quel que soit le type de projet.", + "Name": "La securite de l'information dans la gestion de projet", + "Attributes": [ + { + "Section": "6. Organisation de la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter une estimation des risques prealablement a tout projet pouvant avoir un impact sur le service, et ce quelle que soit la nature du projet. b) Dans la mesure ou un projet affecte ou est susceptible d'affecter le niveau de securite du service, le prestataire doit avertir le commanditaire et l'informer par ecrit des impacts potentiels, des mesures mises en place pour reduire ces impacts ainsi que des risques residuels le concernant." + } + ], + "Checks": [] + }, + { + "Id": "7.1", + "Description": "Le prestataire doit s'assurer que les candidats a l'embauche font l'objet de verifications proportionnees aux exigences metier, a la classification des informations accessibles et aux risques identifies.", + "Name": "Selection des candidats", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de verification des informations concernant son personnel conforme aux lois et reglements en vigueur. Ces verifications s'appliquent a toute personne impliquee dans la fourniture du service et doivent etre proportionnelles a la sensibilite ou a la specificite des informations du commanditaire confiees au prestataire ainsi qu'aux risques identifies. b) Pour les personnels disposant de privileges d'administration eleves sur les composants logiciels et materiels de l'infrastructure, le prestataire doit renforcer les verifications destinees a verifier que les antecedents de ceux-ci ne sont pas incompatibles avec l'exercice de leurs fonctions. Il est entendu par des privileges d'administration eleves, des actions permettant l'elevation de privileges ou la possibilite de realiser des actions sans traces techniques ou de desactiver, alterer les traces techniques." + } + ], + "Checks": [] + }, + { + "Id": "7.2", + "Description": "Les accords contractuels avec les salaries et les sous-traitants doivent preciser leurs responsabilites et celles du prestataire en matiere de securite de l'information.", + "Name": "Conditions d'embauche", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit disposer d'une charte d'ethique integree au reglement interieur, prevoyant notamment que : les prestations sont realisees avec loyaute, discretion et impartialite et dans des conditions de confidentialite des informations traitees ; les personnels ne recourent qu'aux methodes, outils et techniques valides par le prestataire ; les personnels s'engagent a ne pas divulguer d'informations a un tiers, meme anonymisees et decontextualisees, obtenues ou generees dans le cadre de la prestation sauf autorisation formelle et ecrite du commanditaire ; les personnels s'engagent a signaler au prestataire tout contenu manifestement illicite decouvert pendant la prestation ; les personnels s'engagent a respecter la legislation et la reglementation nationale en vigueur et les bonnes pratiques liees a leurs activites. b) Le prestataire doit faire signer la charte d'ethique a l'ensemble des personnes impliquees dans la fourniture du service. c) Le prestataire doit introduire, dans le contrat de travail des personnels disposant de privileges d'administration eleves sur les composants et materiels de l'infrastructure du service, un engagement de responsabilite avec un renvoi aux clauses du code du travail sur la protection du secret des affaires et de la propriete intellectuelle. Il est entendu par des privileges d'administration eleves, des actions permettant l'elevation de privileges ou la possibilite de realiser des actions sans traces techniques ou de desactiver, alterer les traces techniques. d) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible le reglement interieur et la charte d'ethique." + } + ], + "Checks": [] + }, + { + "Id": "7.3", + "Description": "Les salaries du prestataire et, le cas echeant, les sous-traitants doivent suivre un programme de sensibilisation et de formation adapte et regulier concernant la securite de l'information.", + "Name": "Sensibilisation, apprentissage et formations a la securite de l'information", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit sensibiliser a la securite de l'information et aux risques lies a la protection des donnees l'ensemble des personnes impliquees dans la fourniture du service. Il doit leur communiquer les mises a jour des politiques et procedures pertinentes dans le cadre de leurs missions. b) Le prestataire doit documenter et mettre en oeuvre un plan de formation concernant la securite de l'information adapte au service et aux missions des personnels. c) Le responsable de la securite des systemes d'information du prestataire doit valider formellement le plan de formation concernant la securite de l'information." + } + ], + "Checks": [] + }, + { + "Id": "7.4", + "Description": "Le prestataire doit mettre en place un processus disciplinaire formel et communique pour prendre des mesures a l'encontre des salaries ayant enfreint les regles de securite de l'information.", + "Name": "Processus disciplinaire", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus disciplinaire applicable a l'ensemble des personnes impliquees dans la fourniture du service ayant enfreint la politique de securite. b) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible les sanctions encourues en cas d'infraction a la politique de securite." + } + ], + "Checks": [] + }, + { + "Id": "7.5", + "Description": "Les responsabilites et les obligations en matiere de securite de l'information qui restent valables apres un changement ou une rupture du contrat de travail doivent etre definies, communiquees au salarie ou au sous-traitant et appliquees.", + "Name": "Rupture, terme ou modification du contrat de travail", + "Attributes": [ + { + "Section": "7. Securite des ressources humaines", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit definir et attribuer les roles et les responsabilites relatives a la rupture, au terme ou a la modification de tout contrat avec une personne impliquee dans la fourniture du service." + } + ], + "Checks": [] + }, + { + "Id": "8.1", + "Description": "Le prestataire doit identifier les actifs associes a l'information et aux moyens de traitement de l'information et doit etablir et tenir a jour un inventaire de ces actifs.", + "Name": "Inventaire et propriete des actifs", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit tenir a jour l'inventaire de l'ensemble des equipements mettant en oeuvre le service. Cet inventaire doit preciser pour chaque equipement : les informations d'identification de l'equipement (noms, adresses IP, adresses MAC, etc.) ; la fonction de l'equipement ; le modele de l'equipement ; la localisation de l'equipement ; le proprietaire de l'equipement ; le besoin de securite des informations (au sens du chapitre 8.3). b) Le prestataire doit tenir a jour l'inventaire de l'ensemble des logiciels mettant en oeuvre le service. Cet inventaire doit identifier pour chaque logiciel, sa version et les equipements sur lesquels le logiciel est installe. c) Le prestataire doit s'assurer de la validite des licences des logiciels tout au long de la prestation." + } + ], + "Checks": [ + "cloudguard_enabled" + ] + }, + { + "Id": "8.2", + "Description": "Les salaries et les utilisateurs de tiers doivent restituer tous les actifs du prestataire en leur possession au terme de la periode d'emploi, du contrat ou de l'accord.", + "Name": "Restitution des actifs", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de restitution des actifs permettant de s'assurer que chaque personne impliquee dans la fourniture du service restitue l'ensemble des actifs en sa possession a la fin de sa periode d'emploi ou de son contrat." + } + ], + "Checks": [] + }, + { + "Id": "8.3", + "Description": "Les besoins de protection de la confidentialite, de l'integrite et de la disponibilite de l'information doivent etre identifies.", + "Name": "Identification des besoins de securite de l'information", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les differents besoins de securite des informations relatives au service. b) Lorsque le commanditaire confie au prestataire des donnees soumises a des contraintes legales, reglementaires ou sectorielles specifiques, le prestataire doit identifier les besoins de securite specifiques associes a ces contraintes." + } + ], + "Checks": [] + }, + { + "Id": "8.4", + "Description": "Un ensemble de procedures appropriees pour le marquage et la manipulation de l'information doit etre elabore et mis en oeuvre conformement au plan de classification adopte par le prestataire.", + "Name": "Marquage et manipulation de l'information", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Il est recommande que le prestataire documente et mette en oeuvre une procedure pour le marquage et la manipulation de toutes les informations participant a la delivrance du service, conformement a son besoin de securite defini au chapitre 8.3." + } + ], + "Checks": [] + }, + { + "Id": "8.5", + "Description": "Des procedures de gestion des supports amovibles doivent etre mises en oeuvre conformement au plan de classification adopte par le prestataire.", + "Name": "Gestion des supports amovibles", + "Attributes": [ + { + "Section": "8. Gestion des actifs", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure pour la gestion des supports amovibles, conformement au besoin de securite defini au chapitre 8.3. Lorsque des supports amovibles sont utilises sur l'infrastructure technique ou pour des taches d'administration, ces supports doivent etre dedies a un usage." + } + ], + "Checks": [] + }, + { + "Id": "9.1", + "Description": "Une politique de controle d'acces doit etre etablie, documentee et revue en se basant sur les exigences metier et les exigences de securite de l'information. Les regles de controle d'acces et les droits pour chaque utilisateur ou groupe d'utilisateurs doivent etre clairement definis.", + "Name": "Politiques et controle d'acces", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "identity", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de controle d'acces sur la base du resultat de son appreciation des risques et du partage des responsabilites. b) Le prestataire doit reviser annuellement la politique de controle d'acces et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [ + "identity_tenancy_admin_permissions_limited", + "identity_iam_admins_cannot_update_tenancy_admins" + ] + }, + { + "Id": "9.2", + "Description": "Un processus formel d'enregistrement et de desinscription des utilisateurs doit etre mis en oeuvre pour permettre l'attribution des droits d'acces.", + "Name": "Enregistrement et desinscription des utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure d'enregistrement et de desinscription des utilisateurs s'appuyant sur une interface de gestion des comptes et des droits d'acces. Cette procedure doit indiquer quelles donnees doivent etre supprimees au depart d'un utilisateur. b) Le prestataire doit attribuer des comptes nominatifs lors de l'enregistrement des utilisateurs places sous sa responsabilite. c) Le prestataire doit mettre en oeuvre des moyens permettant de s'assurer que la desinscription d'un utilisateur entraine la suppression de tous ses acces aux ressources du systeme d'information du service, ainsi que la suppression de ses donnees conformement a la procedure d'enregistrement et de desinscription (voir exigence 9.2 a))." + } + ], + "Checks": [] + }, + { + "Id": "9.3", + "Description": "Un processus formel de gestion des droits d'acces doit etre mis en oeuvre pour controler l'attribution des droits d'acces a tous les types d'utilisateurs et a tous les systemes et services.", + "Name": "Gestion des droits d'acces", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "identity", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'assurer l'attribution, la modification et le retrait de droits d'acces aux ressources du systeme d'information du service. b) Le prestataire doit mettre a la disposition de ses commanditaires les outils et les moyens qui permettent une differenciation des roles des utilisateurs du service, par exemple suivant leur role fonctionnel. c) Le prestataire doit tenir a jour l'inventaire des utilisateurs sous sa responsabilite disposant de droits d'administration sur les ressources du systeme d'information du service. d) Le prestataire doit etre en mesure de fournir, pour une ressource donnee mettant en oeuvre le service, la liste de tous les utilisateurs y ayant acces, qu'ils soient sous la responsabilite du prestataire ou du commanditaire ainsi que les droits d'acces qui leurs ont ete attribues. e) Le prestataire doit etre en mesure de fournir, pour un utilisateur donne, qu'ils soient sous la responsabilite du prestataire ou du commanditaire, la liste de tous ses droits d'acces sur les differents elements du systeme d'information du service. f) Le prestataire doit definir une liste de droits d'acces incompatibles entre eux. Il doit s'assurer, lors de l'attribution de droits d'acces a un utilisateur qu'il ne possede pas de droits d'acces incompatibles entre eux au titre de la liste precedemment etablie. g) Le prestataire doit inclure dans la procedure de gestion des droits d'acces les actions de revocation ou de suspension des droits de tout utilisateur." + } + ], + "Checks": [ + "identity_tenancy_admin_permissions_limited", + "identity_iam_admins_cannot_update_tenancy_admins", + "identity_tenancy_admin_users_no_api_keys", + "identity_no_resources_in_root_compartment", + "identity_non_root_compartment_exists" + ] + }, + { + "Id": "9.4", + "Description": "Les proprietaires d'actifs doivent verifier les droits d'acces des utilisateurs a intervalles reguliers.", + "Name": "Revue des droits d'acces utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "identity", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit reviser annuellement les droits d'acces des utilisateurs sur son perimetre de responsabilite. b) Le prestataire doit mettre a disposition du commanditaire un outil facilitant la revue des droits d'acces des utilisateurs places sous la responsabilite de ce dernier. c) Le prestataire doit reviser trimestriellement la liste des utilisateurs sur son perimetre de responsabilite pouvant utiliser les comptes techniques mentionnes dans l'exigence 9.2 b)." + } + ], + "Checks": [ + "identity_user_api_keys_rotated_90_days", + "identity_user_auth_tokens_rotated_90_days", + "identity_user_customer_secret_keys_rotated_90_days", + "identity_user_db_passwords_rotated_90_days" + ] + }, + { + "Id": "9.5", + "Description": "L'attribution et l'utilisation des informations secretes d'authentification doivent etre gerees dans le cadre d'un processus de gestion formel incluant une politique de mot de passe robuste et l'utilisation de l'authentification multi-facteur.", + "Name": "Gestion des authentifications des utilisateurs", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "identity", + "Type": "Automated", + "Comment": "a) Le prestataire doit formaliser et mettre en oeuvre des procedures de gestion de l'authentification des utilisateurs. En accord avec les exigences du chapitre 10, celles-ci doivent notamment porter sur : la gestion des moyens d'authentification (emission et reinitialisation de mot de passe, mise a jour des CRL et import des certificats racines en cas d'utilisation de certificats, etc.) ; la mise en place des moyens permettant une authentification a multiples facteurs afin de repondre aux differents cas d'usage du referentiel ; les systemes qui generent des mots de passe ou verifient leur robustesse, lorsqu'une authentification par mot de passe est utilisee. Ils doivent suivre les recommandations de [G_AUTH]. b) Tout mecanisme d'authentification doit prevoir le blocage d'un compte apres un nombre limite de tentatives infructueuses. c) Dans le cadre d'un service SaaS, le prestataire doit proposer a ses commanditaires des moyens d'authentification a multiples facteurs pour l'acces des utilisateurs finaux. d) Lorsque des comptes techniques, non nominatifs, sont necessaires, le prestataire doit mettre en place des mesures obligeant les utilisateurs a s'authentifier avec leur compte nominatif avant de pouvoir acceder a ces comptes techniques." + } + ], + "Checks": [ + "identity_password_policy_minimum_length_14", + "identity_password_policy_prevents_reuse", + "identity_password_policy_expires_within_365_days" + ] + }, + { + "Id": "9.6", + "Description": "L'acces aux interfaces d'administration du service cloud doit etre restreint et protege par des mecanismes d'authentification forte, incluant l'utilisation de dispositifs MFA materiels pour les comptes a privileges.", + "Name": "Acces aux interfaces d'administration", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "identity", + "Type": "Partially Automated", + "Comment": "a) Les comptes d'administration sous la responsabilite du prestataire doivent etre geres a l'aide d'outils et d'annuaires distincts de ceux utilises pour la gestion des comptes utilisateurs places sous la responsabilite du commanditaire. b) Les interfaces d'administration mises a disposition des commanditaires doivent etre distinctes des interfaces d'administration utilisees par le prestataire. c) Les interfaces d'administration mises a disposition des commanditaires ne doivent permettre aucune connexion avec des comptes d'administrateurs sous la responsabilite du prestataire. d) Les interfaces d'administration utilisees par le prestataire ne doivent pas etre accessibles a partir d'un reseau public et ainsi ne doivent permettre aucune connexion des utilisateurs sous la responsabilite du commanditaire. e) Si des interfaces d'administration sont mises a disposition des commanditaires avec un acces via un reseau public, les flux d'administration doivent etre authentifies et chiffres avec des moyens en accord avec les exigences du chapitre 10.2. f) Le prestataire doit mettre en place un systeme d'authentification multifacteur fort pour l'acces : aux interfaces d'administration utilisees par le prestataire ; aux interfaces d'administration dediees aux commanditaires. g) Dans le cadre d'un service SaaS, les interfaces d'administration mises a disposition des commanditaires doivent etre differenciees des interfaces permettant l'acces des utilisateurs finaux. h) Des lors qu'une interface d'administration est accessible depuis un reseau public, le processus d'authentification doit avoir lieu avant toute interaction entre l'utilisateur et l'interface en question. i) Lorsque le prestataire utilise un service de type IaaS comme socle d'un autre type de service (CaaS, PaaS ou SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service IaaS. j) Lorsque le prestataire utilise un service de type CaaS comme socle d'un autre type de service (PaaS ou SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service CaaS. k) Lorsque le prestataire utilise un service de type PaaS comme socle d'un autre type de service (typiquement SaaS), les ressources affectees a l'usage du prestataire ne doivent en aucun cas etre accessibles via l'interface publique mise a disposition des autres commanditaires du service PaaS." + } + ], + "Checks": [ + "identity_user_mfa_enabled_console_access", + "identity_tenancy_admin_users_no_api_keys" + ] + }, + { + "Id": "9.7", + "Description": "L'acces a l'information et aux fonctions d'application des systemes doit etre restreint conformement a la politique de controle d'acces. Les ressources doivent etre protegees contre tout acces public non autorise.", + "Name": "Restriction des acces a l'information", + "Attributes": [ + { + "Section": "9. Controle d'acces et gestion des identites", + "Service": "network", + "Type": "Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre des mesures de cloisonnement appropriees entre ses commanditaires. b) Le prestataire doit mettre en oeuvre des mesures de cloisonnement appropriees entre le systeme d'information du service et ses autres systemes d'information (bureautique, informatique de gestion, gestion technique du batiment, controle d'acces physique, etc.). c) Le prestataire doit concevoir, developper, configurer et deployer le systeme d'information du service en assurant au moins un cloisonnement entre d'une part l'infrastructure technique et d'autre part les equipements necessaires a l'administration des services et des ressources qu'elle heberge. d) Dans le cadre du support technique, si les actions necessaires au diagnostic et a la resolution d'un probleme rencontre par un commanditaire necessitent un acces aux donnees du commanditaire, alors le prestataire doit : n'autoriser l'acces aux donnees du commanditaire qu'apres consentement explicite du commanditaire ; verifier que la personne a qui l'acces doit etre autorise a satisfait aux verifications de l'exigence 7.1.b ; dans le cas d'une intervention realisee a distance par une personne localisee hors de l'Union Europeenne, mettre en oeuvre une passerelle securisee (poste de rebond) par laquelle la personne devra se connecter et permettant une supervision (autorisation ou interdiction des actions, demandes d'explications, etc.) en temps reel, par une personne ayant elle-meme satisfait aux verifications de l'exigence 7.1.b ; considerer les actions menees, une fois l'acces autorise, comme des actions d'administration et les journaliser comme telles ; supprimer l'autorisation d'acces aux donnees du commanditaire au terme de ces actions." + } + ], + "Checks": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "objectstorage_bucket_not_publicly_accessible", + "analytics_instance_access_restricted", + "database_autonomous_database_access_restricted", + "integration_instance_access_restricted" + ] + }, + { + "Id": "10.1", + "Description": "Les donnees stockees dans le cadre du service cloud doivent etre chiffrees au repos en utilisant des algorithmes et des longueurs de cle conformes a l'etat de l'art.", + "Name": "Chiffrement des donnees stockees", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "blockstorage", + "Type": "Automated", + "Comment": "a) Le prestataire doit definir et mettre en oeuvre un mecanisme de chiffrement empechant la recuperation des donnees des commanditaires en cas de reallocation d'une ressource ou de recuperation du support physique. Dans le cas d'un service IaaS ou CaaS, cet objectif pourra par exemple etre atteint par un chiffrement du disque ou du systeme de fichier, lorsque le protocole d'acces en mode fichiers garantit que seuls des blocs vides peuvent etre alloues, ou par un chiffrement par volume dans le cas d'un acces en mode bloc, avec au moins une cle par commanditaire. Dans le cas d'un service PaaS ou SaaS, cet objectif pourra etre atteint en utilisant un chiffrement applicatif dans le perimetre du prestataire, avec au moins une cle par commanditaire. b) Le prestataire doit utiliser une methode de chiffrement des donnees respectant les regles de [CRYPTO_B1]. c) Il est recommande d'utiliser une methode de chiffrement des donnees respectant les recommandations de [CRYPTO_B1]. d) Le prestataire doit mettre en place un chiffrement des donnees sur les supports amovibles et les supports de sauvegarde amenes a quitter le perimetre de securite physique du systeme d'information du service (au sens du chapitre 10), en fonction du besoin de securite des donnees (voir chapitre 8.3)." + } + ], + "Checks": [ + "blockstorage_block_volume_encrypted_with_cmk", + "blockstorage_boot_volume_encrypted_with_cmk", + "objectstorage_bucket_encrypted_with_cmk", + "filestorage_file_system_encrypted_with_cmk" + ] + }, + { + "Id": "10.2", + "Description": "Les flux de donnees entre les composants du service cloud et entre le service et les commanditaires doivent etre chiffres en transit en utilisant des protocoles et des algorithmes conformes a l'etat de l'art.", + "Name": "Chiffrement des flux", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "compute", + "Type": "Partially Automated", + "Comment": "a) Lorsque le prestataire met en oeuvre un mecanisme de chiffrement des flux reseau, celui-ci doit respecter les regles de [CRYPTO_B1]. b) Lorsque le prestataire met en oeuvre un mecanisme de chiffrement des flux reseau, il est recommande que celui-ci respecte les recommandations de [CRYPTO_B1]. c) Si le protocole TLS est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_TLS]. d) Si le protocole IPsec est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_IPSEC]. e) Si le protocole SSH est mis en oeuvre, le prestataire doit appliquer les recommandations de [NT_SSH]." + } + ], + "Checks": [ + "compute_instance_in_transit_encryption_enabled" + ] + }, + { + "Id": "10.3", + "Description": "Les mots de passe doivent etre stockes sous forme hachee en utilisant des algorithmes robustes conformes a l'etat de l'art et les politiques de mot de passe doivent imposer des exigences de complexite adequates.", + "Name": "Hachage des mots de passe", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "identity", + "Type": "Partially Automated", + "Comment": "a) Le prestataire ne doit stocker que l'empreinte des mots de passe des utilisateurs et des comptes techniques. b) Le prestataire doit mettre en oeuvre une fonction de hachage respectant les regles de [CRYPTO_B1]. c) Il est recommande que le prestataire mette en oeuvre une fonction de hachage respectant les recommandations de [CRYPTO_B1]. d) Le prestataire doit generer les empreintes des mots de passe avec une fonction de hachage associee a l'utilisation d'un sel cryptographique respectant les regles de [CRYPTO_B1]." + } + ], + "Checks": [ + "identity_password_policy_minimum_length_14", + "identity_password_policy_prevents_reuse" + ] + }, + { + "Id": "10.4", + "Description": "Des mecanismes de non-repudiation doivent etre mis en oeuvre pour assurer la tracabilite des actions effectuees sur le service cloud, incluant la validation de l'integrite des journaux.", + "Name": "Non repudiation", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "audit", + "Type": "Partially Automated", + "Comment": "a) Lorsque le prestataire met en oeuvre un mecanisme de signature electronique, celui-ci doit respecter les regles de [CRYPTO_B1]. b) Lorsque le prestataire met en oeuvre un mecanisme de signature electronique, il est recommande que celui-ci respecte les recommandations de [CRYPTO_B1]." + } + ], + "Checks": [ + "audit_log_retention_period_365_days" + ] + }, + { + "Id": "10.5", + "Description": "Les secrets cryptographiques (cles, certificats, mots de passe) doivent etre geres de maniere securisee tout au long de leur cycle de vie, incluant la generation, le stockage, la distribution, la rotation et la destruction.", + "Name": "Gestion des secrets", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "kms", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre des cles cryptographiques respectant les regles de [CRYPTO_B2]. b) Il est recommande que le prestataire mette en oeuvre des cles cryptographiques respectant les recommandations de [CRYPTO_B2]. c) Le prestataire doit proteger l'acces aux cles cryptographiques et autres secrets utilises pour le chiffrement des donnees par un moyen adapte : conteneur de securite (logiciel ou materiel) ou support disjoint. d) Le prestataire doit proteger l'acces aux cles cryptographiques et autres secrets utilises pour les taches d'administration par un conteneur de securite adapte, logiciel ou materiel." + } + ], + "Checks": [ + "kms_key_rotation_enabled", + "identity_user_api_keys_rotated_90_days", + "identity_user_customer_secret_keys_rotated_90_days", + "identity_user_auth_tokens_rotated_90_days", + "identity_user_db_passwords_rotated_90_days" + ] + }, + { + "Id": "10.6", + "Description": "Les racines de confiance (certificats racine, autorites de certification) utilisees dans le cadre du service cloud doivent etre gerees de maniere securisee. Les certificats doivent etre valides et utiliser des algorithmes de cle robustes.", + "Name": "Racines de confiance", + "Attributes": [ + { + "Section": "10. Cryptologie", + "Service": "general", + "Type": "Manual", + "Comment": "a) Sur l'infrastructure technique, le prestataire doit utiliser exclusivement des certificats de cle publique issus d'une autorite de certification d'un Etat membre de l'Union Europeenne (les ceremonies de generation des cles maitresses doivent avoir lieu dans un pays membre de l'Union Europeenne et en presence du prestataire)." + } + ], + "Checks": [] + }, + { + "Id": "11.1", + "Description": "Des perimetres de securite doivent etre definis et utilises pour proteger les zones contenant des informations sensibles ou critiques et les moyens de traitement de l'information.", + "Name": "Perimetres de securite physique", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des perimetres de securite, incluant le marquage des zones et les differents moyens de limitation et de controle des acces. b) Le prestataire doit distinguer des zones publiques, des zones privees et des zones sensibles. 11.1.1. Zones publiques : a) Les zones publiques sont accessibles a tous dans les limites de la propriete du prestataire. Le prestataire ne doit heberger aucune ressource devolue au service ou permettant d'acceder a des composantes de celui-ci dans les zones publiques. 11.1.2. Zones privees : a) Les zones privees peuvent heberger : les plateformes et moyens de developpement du service ; les postes d'administration, d'exploitation et de supervision ; les locaux a partir desquels le prestataire opere. 11.1.3. Zones sensibles : a) Les zones sensibles sont reservees a l'hebergement du systeme d'information de production du service hors postes d'administration, d'exploitation et de supervision." + } + ], + "Checks": [] + }, + { + "Id": "11.2", + "Description": "Les zones securisees doivent etre protegees par des controles d'acces physiques adequats pour s'assurer que seul le personnel autorise est admis.", + "Name": "Controle d'acces physique", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "11.2.1. Zones privees : a) Le prestataire doit proteger les zones privees contre les acces non autorises. Pour ce faire, il doit mettre en oeuvre un controle d'acces physique reposant au moins sur un facteur personnel : la connaissance d'un secret, la detention d'un objet ou la biometrie. b) Il est recommande que le prestataire respecte les recommandations de [G_CVAP] pour mettre en oeuvre du controle d'acces physique. c) Le prestataire doit definir et documenter des mesures d'acces physique derogatoires en cas d'urgence. d) Le prestataire doit afficher a l'entree des zones privees un avertissement relatif aux limites et conditions d'acces a ces zones. e) Le prestataire doit definir et documenter les plages horaires et conditions d'acces aux zones privees en fonction des profils des intervenants. f) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de s'assurer que les visiteurs sont systematiquement accompagnes par le prestataire lors de leurs acces et sejours en zone privee. Le prestataire doit conserver une trace de l'identite des visiteurs conformement a la legislation et reglementation en vigueur. g) En cas d'intervention (actions de diagnostic, de maintenance, ou d'administration) en zone privee par un tiers visiteur, le prestataire doit faire superviser (suivre, autoriser, interdire, questionner) les actions par un personnel ayant satisfait aux verifications de l'exigence 7.1.b. h) Le prestataire doit documenter et mettre en oeuvre des mecanismes de surveillance et de detection des acces non autorises aux zones privees. 11.2.2. Zones sensibles : a) Le prestataire doit proteger les zones sensibles contre les acces non autorises. Pour ce faire, il doit mettre en oeuvre un controle d'acces physique reposant au moins sur deux facteurs personnels : la connaissance d'un secret, la detention d'un objet ou la biometrie. b) Il est recommande que le prestataire respecte les recommandations de [G_CVAP] pour la mise en oeuvre du controle d'acces physique. c) Le prestataire doit definir et documenter des mesures d'acces physique derogatoires en cas d'urgence. d) Le prestataire doit afficher a l'entree des zones sensibles un avertissement relatif aux limites et conditions d'acces a ces zones. e) Le prestataire doit definir et documenter les plages horaires et conditions d'acces aux zones sensibles en fonction des profils des intervenants. f) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de s'assurer que les visiteurs sont systematiquement accompagnes par le prestataire lors de leurs acces et sejours en zone sensible. Le prestataire doit conserver une trace de l'identite des visiteurs conformement a la legislation et reglementation en vigueur. g) En cas d'intervention (actions de diagnostic, de maintenance, ou d'administration) en zone sensible par un tiers visiteur, le prestataire doit faire superviser (suivre, autoriser, interdire, questionner) les actions par un personnel ayant satisfait aux verifications de l'exigence 7.1.b. h) Le prestataire doit documenter et mettre en oeuvre des mecanismes de surveillance et de detection des acces non autorises aux zones sensibles. i) Le prestataire doit mettre en place une journalisation des acces physiques aux zones sensibles. Il doit effectuer une revue de ces journaux au moins mensuellement. j) Le prestataire doit mettre en oeuvre les moyens garantissant qu'aucun acces direct n'existe entre une zone publique et une zone sensible." + } + ], + "Checks": [] + }, + { + "Id": "11.3", + "Description": "Des mesures de protection contre les menaces exterieures et environnementales, telles que les catastrophes naturelles, les attaques malveillantes ou les accidents, doivent etre concues et appliquees.", + "Name": "Protection contre les menaces exterieures et environnementales", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de minimiser les risques inherents aux sinistres physiques (incendie, degat des eaux, etc.) et naturels (risques climatiques, inondations, seismes, etc.). b) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de limiter les risques de depart et de propagation de feu ainsi que les risques de degat des eaux. c) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de prevenir et limiter les consequences d'une coupure d'alimentation electrique et permettre une reprise du service conformement aux exigences de disponibilite du service definies dans la convention de service. d) Le prestataire doit documenter et mettre en oeuvre les moyens permettant de maintenir des conditions de temperature et d'humidite adaptees aux equipements. De plus, il doit mettre en oeuvre des mesures permettant de prevenir les pannes de climatisation et d'en limiter les consequences. e) Le prestataire doit documenter et mettre en oeuvre des controles et tests reguliers des equipements de detection et de protection physique." + } + ], + "Checks": [] + }, + { + "Id": "11.4", + "Description": "Des mesures de securite physique pour le travail dans les zones privees et sensibles doivent etre concues et appliquees.", + "Name": "Travail dans les zones privees et sensibles", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit integrer les elements de securite physique dans la politique de securite et l'appreciation des risques conformement au niveau de securite requis par la categorie de la zone. b) Le prestataire doit documenter et mettre en oeuvre des procedures relatives au travail en zones privees et sensibles. Il doit communiquer ces procedures aux intervenants concernes." + } + ], + "Checks": [] + }, + { + "Id": "11.5", + "Description": "Les points d'acces tels que les zones de livraison et de chargement et les autres points par lesquels des personnes non autorisees peuvent penetrer dans les locaux doivent etre controles.", + "Name": "Zones de livraison et de chargement", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Les zones de livraison et de chargement et les autres points par lesquels des personnes non autorisees peuvent penetrer dans les locaux sans etre accompagnees sont considerees comme des zones publiques. b) Le prestataire doit isoler les points d'acces de ces zones vers les zones privees et sensibles, de facon a eviter les acces non autorises, ou a defaut, implementer des mesures compensatoires permettant d'assurer le meme niveau de securite." + } + ], + "Checks": [] + }, + { + "Id": "11.6", + "Description": "Le cablage electrique et de telecommunications transportant des donnees ou supportant des services d'information doit etre protege contre les interceptions, les interferences ou les dommages.", + "Name": "Securite du cablage", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de proteger le cablage electrique et de telecommunication des dommages physiques et des possibilites d'interception. b) Le prestataire doit etablir et tenir a jour un plan de cablage. c) Il est recommande que le prestataire mette en oeuvre des mesures permettant d'identifier les cables (par exemple code couleur, etiquette, etc.) afin d'en faciliter l'exploitation et limiter les erreurs de manipulation." + } + ], + "Checks": [] + }, + { + "Id": "11.7", + "Description": "Les materiels doivent etre entretenus correctement pour garantir leur disponibilite permanente et leur integrite.", + "Name": "Maintenance des materiels", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de s'assurer que les conditions d'installation, de maintenance et d'entretien des equipements du systeme d'information du service heberges en zones privees et sensibles sont compatibles avec les exigences de confidentialite et de disponibilite du service definies dans la convention de service. b) Le prestataire doit souscrire des contrats de maintenance permettant de disposer des mises a jour de securite des logiciels installes sur les equipements du systeme d'information du service. c) Le prestataire doit s'assurer que les supports ne peuvent etre retournes a un tiers que si les donnees du commanditaire y sont stockees chiffrees conformement au chapitre 10.1 ou ont prealablement ete detruites a l'aide d'un mecanisme d'effacement securise par reecriture de motifs aleatoires. d) Le prestataire doit documenter et mettre en oeuvre des mesures permettant de s'assurer que les conditions d'installation, de maintenance et d'entretien des equipements techniques annexes (alimentation electrique, climatisation, incendie, etc.) sont compatibles avec les exigences de disponibilite du service definies dans la convention de service." + } + ], + "Checks": [] + }, + { + "Id": "11.8", + "Description": "Les materiels, les informations ou les logiciels ne doivent pas etre sortis des locaux du prestataire sans autorisation prealable.", + "Name": "Sortie des actifs", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de transfert hors site de donnees du commanditaire, equipements et logiciels. Cette procedure doit necessiter que la direction du prestataire donne son autorisation ecrite. Dans tous les cas, le prestataire doit mettre en oeuvre les moyens permettant de garantir que le niveau de protection en confidentialite et en integrite des actifs durant leur transport est equivalent a celui sur site." + } + ], + "Checks": [] + }, + { + "Id": "11.9", + "Description": "Tous les composants des equipements contenant des supports de stockage doivent etre verifies pour s'assurer que toute donnee sensible et tout logiciel sous licence ont ete supprimes ou ecrases de facon securisee avant leur mise au rebut ou leur reutilisation.", + "Name": "Recyclage securise du materiel", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des moyens permettant d'effacer de maniere securisee par reecriture de motifs aleatoires tout support de donnees mis a disposition d'un commanditaire. Si l'espace de stockage est chiffre dans le cadre de l'exigence 10.1.a), l'effacement peut etre realise par un effacement securise de la cle de chiffrement." + } + ], + "Checks": [] + }, + { + "Id": "11.10", + "Description": "Le materiel en attente d'utilisation doit etre protege de maniere adequate.", + "Name": "Materiel en attente d'utilisation", + "Attributes": [ + { + "Section": "11. Securite physique et environnementale", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de protection du materiel en attente d'utilisation." + } + ], + "Checks": [] + }, + { + "Id": "12.1", + "Description": "Les procedures d'exploitation doivent etre documentees et mises a disposition de tous les utilisateurs concernes.", + "Name": "Procedures d'exploitation documentees", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter les procedures d'exploitation, les tenir a jour et les rendre accessibles au personnel concerne." + } + ], + "Checks": [] + }, + { + "Id": "12.2", + "Description": "Les changements apportes au systeme d'information du prestataire, aux processus metier, aux moyens de traitement de l'information et aux systemes qui ont une incidence sur la securite de l'information doivent etre geres.", + "Name": "Gestion des changements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de gestion des changements apportes aux systemes et moyens de traitement de l'information. b) Le prestataire doit documenter et mettre en oeuvre une procedure permettant, en cas d'operations realisees par le prestataire et pouvant avoir un impact sur la securite ou la disponibilite du service, de communiquer au plus tot a l'ensemble de ses commanditaires les informations suivantes : la date et l'heure programmees du debut et de la fin des operations ; la nature des operations ; les impacts sur la securite ou la disponibilite du service ; le contact au sein du prestataire. c) Dans le cadre d'un service PaaS, le prestataire doit informer au plus tot le commanditaire de toute modification a venir sur des elements logiciels sous sa responsabilite des lors que la compatibilite complete ne peut etre assuree. d) Le prestataire doit informer au plus tot le commanditaire de toute modification a venir sur les elements du service des lors qu'elle est susceptible d'occasionner une perte de fonctionnalite pour le commanditaire." + } + ], + "Checks": [ + "cloudguard_enabled", + "audit_log_retention_period_365_days" + ] + }, + { + "Id": "12.3", + "Description": "Les environnements de developpement, de test et d'exploitation doivent etre separes pour reduire les risques d'acces non autorise ou de changements non souhaites dans l'environnement d'exploitation.", + "Name": "Separation des environnements de developpement, de test et d'exploitation", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "identity", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures permettant de separer physiquement les environnements lies a la production du service des autres environnements, dont les environnements de developpement." + } + ], + "Checks": [ + "identity_non_root_compartment_exists", + "identity_no_resources_in_root_compartment" + ] + }, + { + "Id": "12.4", + "Description": "Des mesures de detection, de prevention et de recuperation conjuguees a une sensibilisation des utilisateurs doivent etre mises en oeuvre pour proteger le systeme d'information contre les codes malveillants.", + "Name": "Mesures contre les codes malveillants", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures de detection, de prevention et de restauration pour se proteger des codes malveillants. Le perimetre d'application de cette exigence sur le systeme d'information du service doit necessairement contenir les postes utilisateurs sous la responsabilite du prestataire et les flux entrants sur ce meme systeme d'information. b) Le prestataire doit documenter et mettre en oeuvre une sensibilisation de ses employes aux risques lies aux codes malveillants et aux bonnes pratiques pour reduire l'impact d'une infection." + } + ], + "Checks": [ + "cloudguard_enabled", + "events_rule_cloudguard_problems" + ] + }, + { + "Id": "12.5", + "Description": "Des copies de sauvegarde des informations, des logiciels et des images systeme doivent etre effectuees et testees regulierement conformement a une politique de sauvegarde convenue.", + "Name": "Sauvegarde des informations", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "objectstorage", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de sauvegarde et de restauration des donnees sous sa responsabilite dans le cadre du service. Cette politique doit prevoir une sauvegarde quotidienne de l'ensemble des donnees (informations, logiciels, configurations, etc.) sous la responsabilite du prestataire dans le cadre du service. b) Le prestataire doit documenter et mettre en oeuvre des mesures de protection des sauvegardes conformement a la politique de controle d'acces (voir chapitre 9). Cette politique doit prevoir une revue mensuelle des traces d'acces aux sauvegardes. c) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester regulierement la restauration des sauvegardes. d) Le prestataire doit localiser les sauvegardes a une distance suffisante des equipements principaux en coherence avec les resultats de l'appreciation de risques et permettant de faire face a des sinistres majeurs. Les sauvegardes sont assujetties aux memes exigences de localisation que les donnees operationnelles. Le ou les sites de sauvegarde sont assujettis aux memes exigences de securite que le site principal, en particulier celles listees aux chapitres 8 et 11. Les communications entre site principal et site de sauvegarde doivent etre protegees par chiffrement, conformement aux exigences du chapitre 10." + } + ], + "Checks": [ + "objectstorage_bucket_versioning_enabled" + ] + }, + { + "Id": "12.6", + "Description": "Des journaux d'evenements enregistrant les activites des utilisateurs, les exceptions, les defaillances et les evenements de securite de l'information doivent etre crees, tenus a jour et regulierement revus.", + "Name": "Journalisation des evenements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "audit", + "Type": "Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique de journalisation incluant au minimum les elements suivants : la liste des sources de collecte ; la liste des evenements a journaliser par source ; l'objet de la journalisation par evenement ; la frequence de la collecte et base de temps utilisee ; la duree de retention locale et centralisee ; les mesures de protection des journaux (dont chiffrement et duplication) ; la localisation des journaux. b) Le prestataire doit generer et collecter les evenements suivants : les activites des utilisateurs liees a la securite de l'information ; la modification des droits d'acces dans le perimetre de sa responsabilite ; les evenements issus des mecanismes de lutte contre les codes malveillants (voir chapitre 12.4) ; les exceptions ; les defaillances ; tout autre evenement lie a la securite de l'information. c) Le prestataire doit conserver les evenements issus de la journalisation pendant une duree minimale de six mois sous reserve du respect des exigences legales et reglementaires. d) Le prestataire doit fournir, sur demande d'un commanditaire, l'ensemble des evenements le concernant. e) Il est recommande que le systeme de journalisation mis en place par le prestataire respecte les recommandations de [NT_JOURNAL]." + } + ], + "Checks": [ + "audit_log_retention_period_365_days", + "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", + "network_vcn_subnet_flow_logs_enabled", + "objectstorage_bucket_logging_enabled" + ] + }, + { + "Id": "12.7", + "Description": "Les moyens de journalisation et les informations journalisees doivent etre proteges contre les risques de falsification et les acces non autorises.", + "Name": "Protection de l'information journalisee", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "audit", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit proteger les equipements de journalisation et les evenements journalises contre les atteintes a leur disponibilite, integrite ou confidentialite, conformement au chapitre 3.2 de [NT_JOURNAL]. b) Le prestataire doit gerer le dimensionnement de l'espace de stockage de l'ensemble des equipements hebergeant une ou plusieurs sources de collecte afin de permettre la conservation locale des evenements journalises prevue par la politique de journalisation des evenements. Cette gestion du dimensionnement doit prendre en compte les evolutions du systeme d'information. c) Le prestataire doit transferer les evenements journalises en assurant leur protection en confidentialite et en integrite, sur un ou plusieurs serveurs centraux dedies et doit les stocker sur une machine physique distincte de celle qui les a generes. d) Le prestataire doit mettre en place une sauvegarde des evenements collectes suivant une politique adaptee. e) Le prestataire doit executer les processus de journalisation et de collecte des evenements avec des comptes disposant de privileges necessaires et suffisants et doit limiter l'acces aux evenements journalises conformement a la politique de controle d'acces (voir chapitre 9.1)." + } + ], + "Checks": [ + "audit_log_retention_period_365_days", + "objectstorage_bucket_encrypted_with_cmk", + "objectstorage_bucket_not_publicly_accessible" + ] + }, + { + "Id": "12.8", + "Description": "Les horloges de tous les systemes de traitement de l'information pertinents d'un organisme ou d'un domaine de securite doivent etre synchronisees sur une source de reference temporelle unique.", + "Name": "Synchronisation des horloges", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une synchronisation des horloges de l'ensemble des equipements sur une ou plusieurs sources de temps internes coherentes entre elles. Ces sources pourront elles-memes etre synchronisees sur plusieurs sources fiables externes, sauf pour les reseaux isoles. b) Le prestataire doit mettre en place l'horodatage de chaque evenement journalise." + } + ], + "Checks": [] + }, + { + "Id": "12.9", + "Description": "Les evenements de securite doivent etre analyses et correles afin de detecter les incidents de securite. Des systemes de detection et de correlation doivent etre mis en oeuvre.", + "Name": "Analyse et correlation des evenements", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une infrastructure permettant l'analyse et la correlation des evenements enregistres par le systeme de journalisation afin de detecter les evenements susceptibles d'affecter la securite du systeme d'information du service, en temps reel ou a posteriori pour des evenements remontant jusqu'a six mois. b) Il est recommande de s'appuyer sur le referentiel d'exigences des prestataires de detection d'incidents de securite [PDIS] pour la mise en place et l'exploitation de l'infrastructure d'analyse et de correlation des evenements. c) Le prestataire doit acquitter les alarmes remontees par l'infrastructure d'analyse et de correlation des evenements au moins quotidiennement." + } + ], + "Checks": [ + "cloudguard_enabled", + "events_rule_cloudguard_problems", + "events_notification_topic_and_subscription_exists", + "events_rule_iam_group_changes", + "events_rule_iam_policy_changes", + "events_rule_user_changes", + "events_rule_vcn_changes", + "events_rule_network_gateway_changes", + "events_rule_route_table_changes", + "events_rule_security_list_changes", + "events_rule_network_security_group_changes" + ] + }, + { + "Id": "12.10", + "Description": "Des regles regissant l'installation de logiciels par les utilisateurs doivent etre etablies et mises en oeuvre. Les systemes doivent etre geres de maniere centralisee et les correctifs appliques regulierement.", + "Name": "Installation de logiciels sur des systemes en exploitation", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de controler l'installation de logiciels sur les equipements du systeme d'information du service. b) Le prestataire doit documenter et mettre en oeuvre une procedure de gestion de la configuration des environnements logiciels mis a la disposition du commanditaire, notamment pour leur maintien en condition de securite. c) Le prestataire doit fournir une capacite d'inspection et de suppression, si necessaire, des entrants (controle de l'authenticite et de l'innocuite des mises a jour, controle de l'innocuite des outils fournis, etc.) relatifs au perimetre de l'infrastructure technique : cette capacite d'inspection et de suppression doit generer des journaux d'activite et doit pouvoir faire l'objet d'un audit de code ; les entrants doivent etre traites sur des dispositifs specifiques operes et maintenus par le prestataire et heberges dans une zone cloisonnee du reste de l'infrastructure (du type zone demilitarisee telle que definie dans [G_INT])." + } + ], + "Checks": [] + }, + { + "Id": "12.11", + "Description": "Les informations sur les vulnerabilites techniques des systemes d'information utilises doivent etre obtenues en temps voulu, l'exposition du prestataire a ces vulnerabilites doit etre evaluee et les mesures appropriees doivent etre prises pour traiter le risque associe.", + "Name": "Gestion des vulnerabilites techniques", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus de veille permettant de gerer les vulnerabilites techniques des logiciels et des systemes utilises dans le systeme d'information du service. b) Le prestataire doit evaluer son exposition a ces vulnerabilites en les incluant dans l'appreciation des risques et appliquer les mesures de traitement du risque adaptees." + } + ], + "Checks": [ + "cloudguard_enabled" + ] + }, + { + "Id": "12.12", + "Description": "L'administration des systemes d'information du service cloud doit etre effectuee de maniere securisee via des canaux dedies et des protocoles securises.", + "Name": "Administration", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure obligeant les administrateurs sous sa responsabilite a utiliser des terminaux dedies pour la realisation exclusive des taches d'administration, en accord avec le chapitre 4.1 intitule 'poste et reseau d'administration' de [NT_ADMIN]. Il doit les maitriser et les maintenir a jour. b) Le prestataire doit mettre en place des mesures de durcissement de la configuration des terminaux utilises pour les taches d'administration, notamment celles du chapitre 4.2 intitule 'securisation du socle' de [NT_ADMIN]. c) Lorsque le prestataire autorise une situation de mobilite pour les administrateurs sous sa responsabilite, il doit l'encadrer par une politique documentee. La solution mise en oeuvre doit assurer que le niveau de securite de cette situation de mobilite est au moins equivalent au niveau de securite hors situation de mobilite (voir chapitres 9.6 et 9.7). Cette solution doit notamment inclure : l'utilisation d'un tunnel chiffre, non debrayable et non contournable, pour l'ensemble des flux (voir chapitre 10.2) ; le chiffrement integral du disque (voir chapitre 10.1)." + } + ], + "Checks": [] + }, + { + "Id": "12.13", + "Description": "Le telediagnostic et la telemaintenance des composants de l'infrastructure doivent etre encadres par des procedures de securite specifiques.", + "Name": "Telediagnostic et telemaintenance des composants de l'infrastructure", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "general", + "Type": "Manual", + "Comment": "a) Dans le cadre du telediagnostic ou de la telemaintenance de composants de l'infrastructure, considerant les risques d'atteinte a la confidentialite des donnees des commanditaires, le prestataire doit : verifier que la personne a qui l'acces doit etre autorise a satisfait aux verifications de l'exigence 7.1.b ; dans le cas d'une intervention realisee par une personne n'ayant pas satisfait aux verifications de l'exigence 7.1.b, mettre en oeuvre une passerelle securisee (poste de rebond) par laquelle la personne devra se connecter et permettant une supervision des actions (autorisation ou interdiction des actions, demande d'explications, etc.) en temps reel, par une personne ayant elle-meme satisfait aux verifications de l'exigence 7.1.b. La passerelle securisee devra repondre aux objectifs de securite specifies dans [G_EXT] ; considerer les actions menees, une fois l'acces autorise, comme des actions d'administration et les journaliser comme telles ; supprimer l'autorisation d'acces a l'issue de l'intervention." + } + ], + "Checks": [] + }, + { + "Id": "12.14", + "Description": "Les flux sortants de l'infrastructure du service cloud doivent etre surveilles afin de detecter et de prevenir les exfiltrations de donnees et les communications non autorisees.", + "Name": "Surveillance des flux sortants de l'infrastructure", + "Attributes": [ + { + "Section": "12. Securite liee a l'exploitation", + "Service": "network", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit fournir une capacite d'inspection et de suppression des sortants de l'infrastructure technique relatifs au perimetre du service (informations de facturation, les eventuels journaux necessaires au traitement d'incidents, etc.) : les sortants doivent pouvoir etre expurges des donnees pouvant porter atteinte a la confidentialite des donnees des commanditaires ; cette capacite d'inspection et de suppression doit generer des journaux d'activite et doit pouvoir faire l'objet d'un audit de code ; les sortants sont traites sur des dispositifs specifiques operes et maintenus par le prestataire, et heberges dans une zone cloisonnee du reste de l'infrastructure (du type zone demilitarisee telle que definie dans [G_INT])." + } + ], + "Checks": [ + "network_vcn_subnet_flow_logs_enabled" + ] + }, + { + "Id": "13.1", + "Description": "Le prestataire doit etablir et maintenir une cartographie complete et a jour de son systeme d'information, incluant les reseaux, les flux et les composants.", + "Name": "Cartographie du systeme d'information", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit etablir et tenir a jour une cartographie du systeme d'information du service, en lien avec l'inventaire des actifs (voir chapitre 8.1), comprenant au minimum les elements suivants : la liste des ressources materielles ou virtualisees ; les noms et fonctions des applications, supportant le service ; le schema d'architecture reseau au niveau 3 du modele OSI sur lequel les points nevralgiques sont identifies : les points d'interconnexions, notamment avec les reseaux tiers et publics ; les reseaux, sous-reseaux, notamment les reseaux d'administration ; les equipements assurant des fonctions de securite (filtrage, authentification, chiffrement, etc.) ; les serveurs hebergeant des donnees ou assurant des fonctions sensibles ; la matrice des flux reseau autorises en precisant : leur description technique (services, protocoles et ports) ; la justification metier ou d'infrastructure technique ; le cas echeant, lorsque des services, protocoles ou ports reputes non surs sont utilises, les mesures compensatoires mises en place, dans la logique de defense en profondeur. b) Le prestataire doit reviser au moins annuellement la cartographie." + } + ], + "Checks": [ + "cloudguard_enabled", + "network_vcn_subnet_flow_logs_enabled" + ] + }, + { + "Id": "13.2", + "Description": "Les reseaux doivent etre cloisonnes et les flux entre les segments doivent etre filtres selon le principe du moindre privilege. Les groupes de securite et les listes de controle d'acces reseau doivent etre configures de maniere restrictive.", + "Name": "Cloisonnement des reseaux", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "network", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre, pour le systeme d'information du service, les mesures de cloisonnement (logique, physique ou par chiffrement) pour separer les flux reseau selon : la sensibilite des informations transmises ; la nature des flux (production, administration, supervision, etc.) ; le domaine d'appartenance des flux (des commanditaires - avec distinction par commanditaire ou ensemble de commanditaires, du prestataire, des tiers, etc.) ; le domaine technique (traitement, stockage, etc.). b) Le prestataire doit cloisonner, physiquement ou par chiffrement, tous les flux de donnees internes au systeme d'information du service vis-a-vis de tout autre systeme d'information. Lorsque ce cloisonnement est realise par chiffrement, il est realise en accord avec les exigences du chapitre 10.2. c) Dans le cas ou le reseau d'administration de l'infrastructure technique ne fait pas l'objet d'un cloisonnement physique, les flux d'administration doivent transiter dans un tunnel chiffre, en accord avec les exigences du chapitre 10.2. d) Le prestataire doit mettre en place et configurer un pare-feu applicatif pour proteger les interfaces d'administration destinees a ses commanditaires et exposees sur un reseau public. e) Le prestataire doit mettre en oeuvre sur l'ensemble des interfaces d'administration et de supervision de l'infrastructure technique du service un mecanisme de filtrage n'autorisant que les connexions legitimes identifiees dans la matrice des flux autorises." + } + ], + "Checks": [ + "network_default_security_list_restricts_traffic", + "network_security_group_ingress_from_internet_to_ssh_port", + "network_security_group_ingress_from_internet_to_rdp_port", + "network_security_list_ingress_from_internet_to_ssh_port", + "network_security_list_ingress_from_internet_to_rdp_port", + "network_vcn_subnet_flow_logs_enabled" + ] + }, + { + "Id": "13.3", + "Description": "Les reseaux doivent etre surveilles de maniere continue afin de detecter les activites anormales ou malveillantes.", + "Name": "Surveillance des reseaux", + "Attributes": [ + { + "Section": "13. Securite des communications", + "Service": "network", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit disposer une ou plusieurs sondes de detection d'incidents de securite sur le systeme d'information du service. Ces sondes doivent notamment permettre la supervision de chacune des interconnexions du systeme d'information du service avec des systemes d'information tiers et des reseaux publics. Ces sondes doivent etre des sources de collecte pour l'infrastructure d'analyse et de correlation des evenements (voir chapitre 12.9)." + } + ], + "Checks": [ + "cloudguard_enabled", + "network_vcn_subnet_flow_logs_enabled" + ] + }, + { + "Id": "14.1", + "Description": "Des regles de developpement securise des logiciels et des systemes doivent etre etablies et appliquees au sein du prestataire.", + "Name": "Politique de developpement securise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des regles de developpement securise des logiciels et des systemes, et les appliquer aux developpements internes. b) Le prestataire doit documenter et mettre en oeuvre une formation adaptee en developpement securise aux employes concernes." + } + ], + "Checks": [] + }, + { + "Id": "14.2", + "Description": "Les changements apportes aux systemes dans le cycle de developpement doivent etre geres a l'aide de procedures formelles de controle des changements.", + "Name": "Procedures de controle des changements de systeme", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de controle des changements apportes au systeme d'information du service. b) Le prestataire doit documenter et mettre en oeuvre une procedure de validation des changements apportes au systeme d'information du service sur un environnement de pre-production avant leur mise en production. c) Le prestataire doit conserver un historique des versions des logiciels et des systemes (developpements internes ou externes, produits commerciaux) mis en oeuvre pour permettre de reconstituer, le cas echeant dans un environnement de test, un environnement complet tel qu'il etait mis en oeuvre a une date donnee. La duree de conservation de cet historique doit etre en accord avec celle des sauvegardes (voir chapitre 12.5)." + } + ], + "Checks": [ + "cloudguard_enabled", + "audit_log_retention_period_365_days" + ] + }, + { + "Id": "14.3", + "Description": "Lorsque les plateformes d'exploitation sont modifiees, les applications critiques metier doivent etre revues et testees afin de verifier qu'il n'y a pas d'effet indesirable sur l'activite ou la securite du prestataire.", + "Name": "Revue technique des applications apres changement apporte a la plateforme d'exploitation", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester, prealablement a leur mise en production, l'ensemble des applications afin de verifier l'absence de tout effet indesirable sur l'activite ou sur la securite du service." + } + ], + "Checks": [] + }, + { + "Id": "14.4", + "Description": "Les environnements de developpement doivent etre securises et isoles des environnements de production.", + "Name": "Environnement de developpement securise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "identity", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit mettre en oeuvre un environnement securise de developpement permettant de gerer l'integralite du cycle de developpement du systeme d'information du service. b) Le prestataire doit prendre en compte les environnements de developpement dans l'appreciation des risques et en assurer la protection conformement au present referentiel." + } + ], + "Checks": [ + "identity_non_root_compartment_exists", + "identity_no_resources_in_root_compartment" + ] + }, + { + "Id": "14.5", + "Description": "Le prestataire doit superviser et surveiller l'activite de developpement externalise du systeme.", + "Name": "Developpement externalise", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de superviser et de controler l'activite de developpement externalise des logiciels et des systemes. Cette procedure doit s'assurer que l'activite de developpement externalise soit conforme a la politique de developpement securise du prestataire et permette d'atteindre un niveau de securite du developpement externe equivalent a celui d'un developpement interne (voir exigence 14.1 a))." + } + ], + "Checks": [] + }, + { + "Id": "14.6", + "Description": "Des tests de securite et de conformite doivent etre effectues tout au long du cycle de developpement et apres chaque changement significatif.", + "Name": "Test de la securite et conformite du systeme", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit soumettre les systemes d'information, nouveaux ou mis a jour, a des tests de conformite et de fonctionnalite de securite pendant le developpement. Il doit documenter et mettre en oeuvre une procedure de test qui identifie : les taches a realiser ; les donnees d'entree ; les resultats attendus en sortie." + } + ], + "Checks": [ + "cloudguard_enabled" + ] + }, + { + "Id": "14.7", + "Description": "Les donnees de test doivent etre soigneusement selectionnees, protegees et controlees.", + "Name": "Protection des donnees de test", + "Attributes": [ + { + "Section": "14. Acquisition, developpement et maintenance des systemes d'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'assurer l'integrite des donnees de tests utilises en pre-production. b) Si le prestataire souhaite utiliser des donnees du commanditaire issues de la production pour realiser des tests, le prestataire doit prealablement obtenir l'accord du commanditaire et les anonymiser. Le prestataire doit assurer la confidentialite des donnees lors de leur anonymisation." + } + ], + "Checks": [] + }, + { + "Id": "15.1", + "Description": "Le prestataire doit identifier les tiers ayant acces a l'information ou aux moyens de traitement de l'information et evaluer les risques associes.", + "Name": "Identification des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit tenir a jour une liste exhaustive des tiers participant a la mise en oeuvre du service (hebergeur, developpeur, integrateur, archiveur, sous-traitant operant sur site ou a distance, fournisseurs de climatisation, etc.). Cette liste doit preciser la contribution du tiers au service et au traitement des donnees a caractere personnel. Elle doit tenir compte des cas de sous-traitance a plusieurs niveaux. b) Le prestataire doit tenir a disposition du commanditaire la liste de l'ensemble des tiers qui peuvent acceder aux donnees et l'informer de tout changement de sous-traitants au sens de l'article 28 du [RGPD] afin que le commanditaire puisse emettre des objections a cet egard." + } + ], + "Checks": [] + }, + { + "Id": "15.2", + "Description": "Tous les aspects pertinents de la securite de l'information doivent etre traites dans les accords conclus avec les tiers.", + "Name": "La securite dans les accords conclus avec les tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit exiger des tiers participant a la mise en oeuvre du service, dans leur contribution au service, un niveau de securite au moins equivalent a celui qu'il s'engage a maintenir dans sa propre politique de securite. Il doit le faire au travers d'exigences, adaptees a chaque tiers et a sa contribution au service, dans les cahiers des charges ou dans les clauses de securite des accords de partenariat. Le prestataire doit inclure ces exigences dans les contrats conclus avec les tiers. b) Le prestataire doit contractualiser, avec chacun des tiers participant a la mise en oeuvre du service, des clauses d'audit permettant a un organisme de qualification de verifier que ces tiers respectent les exigences du present referentiel. c) Le prestataire doit definir et attribuer les roles et les responsabilites relatives a la modification ou a la fin du contrat le liant a un tiers participant a la mise en oeuvre du service." + } + ], + "Checks": [] + }, + { + "Id": "15.3", + "Description": "Le prestataire doit surveiller, revoir et auditer a intervalles reguliers la prestation des services des tiers.", + "Name": "Surveillance et revue des services des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de controler regulierement les mesures mises en place par les tiers participant a la mise en oeuvre du service pour respecter les exigences du present referentiel, conformement au chapitre 18.3." + } + ], + "Checks": [] + }, + { + "Id": "15.4", + "Description": "Les changements dans les services des tiers, incluant le maintien et l'amelioration des politiques, procedures et mesures existantes de securite de l'information, doivent etre geres.", + "Name": "Gestion des changements apportes dans les services des tiers", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de suivi des changements apportes par les tiers participant a la mise en oeuvre du service susceptibles d'affecter le niveau de securite du systeme d'information du service. b) Dans la mesure ou un changement de tiers participant a la mise en oeuvre du service affecte le niveau de securite du service, le prestataire doit en informer l'ensemble des commanditaires sans delais conformement au chapitre 12.2 et mettre en oeuvre les mesures permettant de retablir le niveau de securite precedent." + } + ], + "Checks": [] + }, + { + "Id": "15.5", + "Description": "Les personnes intervenant dans le cadre du service cloud doivent etre soumises a des engagements de confidentialite.", + "Name": "Engagements de confidentialite", + "Attributes": [ + { + "Section": "15. Relations avec les tiers", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de reviser au moins annuellement les exigences en matiere d'engagements de confidentialite ou de non-divulgation vis-a-vis des tiers participant a la mise en oeuvre du service." + } + ], + "Checks": [] + }, + { + "Id": "16.1", + "Description": "Des responsabilites et des procedures de gestion doivent etre etablies pour garantir une reponse rapide, efficace et ordonnee aux incidents lies a la securite de l'information.", + "Name": "Responsabilites et procedures", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'apporter des reponses rapides et efficaces aux incidents de securite. Ces procedures doivent definir les moyens et delais de communication des incidents de securite a l'ensemble des commanditaires concernes ainsi que le niveau de confidentialite exige pour cette communication. b) Le prestataire doit informer ses employes et l'ensemble des tiers participant a la mise en oeuvre du service de cette procedure. c) Le prestataire doit documenter toute violation de donnees a caractere personnel et en informer son commanditaire. La violation doit etre notifiee a la CNIL si elle presente un risque pour les droits et libertes des personnes concernees. Elle doit faire l'objet d'une information aupres des personnes concernees lorsque le risque pour leur vie privee est eleve." + } + ], + "Checks": [] + }, + { + "Id": "16.2", + "Description": "Les evenements lies a la securite de l'information doivent etre signales dans les meilleurs delais par les voies hierarchiques appropriees. Des mecanismes de detection et de notification automatises doivent etre mis en oeuvre.", + "Name": "Signalements lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure exigeant de ses employes et des tiers participant a la mise en oeuvre du service qu'ils lui rendent compte de tout incident de securite, avere ou suspecte ainsi que de toute faille de securite. b) Le prestataire doit documenter et mettre en oeuvre une procedure permettant a l'ensemble des commanditaires de signaler tout incident de securite, avere ou suspecte et toute faille de securite. c) Le prestataire doit communiquer sans delai aux commanditaires les incidents de securite et les preconisations associees pour en limiter les impacts. Il doit permettre au commanditaire de choisir les niveaux de gravite des incidents pour lesquels il souhaite etre informe. d) Le prestataire doit communiquer les incidents de securite aux autorites competentes conformement aux exigences legales et reglementaires en vigueur." + } + ], + "Checks": [ + "cloudguard_enabled", + "events_notification_topic_and_subscription_exists", + "events_rule_cloudguard_problems" + ] + }, + { + "Id": "16.3", + "Description": "Les evenements lies a la securite de l'information doivent etre apprecies et il doit etre decide s'il est necessaire de les classer comme incidents lies a la securite de l'information.", + "Name": "Appreciation des evenements et prise de decision", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "events", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit apprecier les evenements lies a la securite de l'information et decider s'il faut les qualifier en incidents de securite. Pour l'appreciation, il doit s'appuyer sur une ou plusieurs echelles (estimation, evaluation, etc.) partagees avec le commanditaire. Note : Les incidents de securite incluent les violations de donnees a caractere personnel. b) Le prestataire doit utiliser une classification permettant d'identifier clairement les incidents de securite touchant des donnees relatives aux commanditaires, conformement aux resultats de l'appreciation des risques. Cette classification doit inclure les violations de donnees a caractere personnel." + } + ], + "Checks": [ + "events_rule_cloudguard_problems" + ] + }, + { + "Id": "16.4", + "Description": "Les incidents lies a la securite de l'information doivent etre traites conformement aux procedures documentees.", + "Name": "Reponse aux incidents lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit traiter les incidents de securite jusqu'a leur resolution et doit informer les commanditaires conformement aux procedures." + } + ], + "Checks": [] + }, + { + "Id": "16.5", + "Description": "Les connaissances acquises lors de l'analyse et du traitement des incidents lies a la securite de l'information doivent etre exploitees pour reduire la probabilite ou l'impact d'incidents futurs.", + "Name": "Tirer des enseignements des incidents lies a la securite de l'information", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un processus d'amelioration continue afin de diminuer l'occurrence et l'impact de types d'incidents de securite deja traites." + } + ], + "Checks": [] + }, + { + "Id": "16.6", + "Description": "Le prestataire doit definir et appliquer des procedures pour l'identification, le recueil, l'acquisition et la preservation de preuves. Les journaux d'audit doivent etre proteges et valides.", + "Name": "Recueil de preuves", + "Attributes": [ + { + "Section": "16. Gestion des incidents lies a la securite de l'information", + "Service": "audit", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant d'enregistrer les informations relatives aux incidents de securite et pouvant servir d'elements de preuve." + } + ], + "Checks": [ + "audit_log_retention_period_365_days" + ] + }, + { + "Id": "17.1", + "Description": "Le prestataire doit determiner ses exigences en matiere de securite de l'information et de continuite du management de la securite de l'information dans des situations defavorables, par exemple lors d'une crise ou d'un sinistre.", + "Name": "Organisation de la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre oeuvre un plan de continuite d'activite prenant en compte la securite de l'information. b) Le prestataire doit reviser annuellement le plan de continuite d'activite du service et a chaque changement majeur pouvant avoir un impact sur le service." + } + ], + "Checks": [] + }, + { + "Id": "17.2", + "Description": "Le prestataire doit etablir, documenter, mettre en oeuvre et maintenir des processus, des procedures et des mesures de controle pour assurer le niveau requis de continuite de la securite de l'information au cours d'une situation defavorable. Les services doivent etre deployes en multi-AZ.", + "Name": "Mise en oeuvre de la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "objectstorage", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre des procedures permettant de maintenir ou de restaurer l'exploitation du service et d'assurer la disponibilite des informations au niveau et dans les delais pour lesquels le prestataire s'est engage vis-a-vis du commanditaire dans la convention de service." + } + ], + "Checks": [ + "objectstorage_bucket_versioning_enabled" + ] + }, + { + "Id": "17.3", + "Description": "Le prestataire doit verifier a intervalles reguliers les mesures de continuite de la securite de l'information mises en oeuvre afin de s'assurer qu'elles sont valables et efficaces dans des situations defavorables.", + "Name": "Verifier, revoir et evaluer la continuite d'activite", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure permettant de tester le plan de continuite d'activites afin de s'assurer qu'il est pertinent et efficace en situation de crise." + } + ], + "Checks": [] + }, + { + "Id": "17.4", + "Description": "Les moyens de traitement de l'information doivent etre mis en oeuvre avec suffisamment de redondance pour repondre aux exigences de disponibilite. Les mecanismes de protection contre la suppression accidentelle doivent etre actives.", + "Name": "Disponibilite des moyens de traitement de l'information", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "objectstorage", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre les mesures qui lui permettent de repondre au besoin de disponibilite du service defini dans la convention de service (voir chapitre 19.1)." + } + ], + "Checks": [ + "objectstorage_bucket_versioning_enabled" + ] + }, + { + "Id": "17.5", + "Description": "La configuration de l'infrastructure technique du service cloud doit etre sauvegardee regulierement afin de permettre sa restauration en cas de sinistre.", + "Name": "Sauvegarde de la configuration de l'infrastructure technique", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une procedure de sauvegarde hors-ligne de la configuration de l'infrastructure technique." + } + ], + "Checks": [] + }, + { + "Id": "17.6", + "Description": "Le prestataire doit mettre a disposition du commanditaire un dispositif de sauvegarde de ses donnees, permettant la restauration en cas de sinistre.", + "Name": "Mise a disposition d'un dispositif de sauvegarde des donnees du commanditaire", + "Attributes": [ + { + "Section": "17. Continuite d'activite", + "Service": "objectstorage", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre a disposition du commanditaire un service de sauvegarde de ses donnees." + } + ], + "Checks": [ + "objectstorage_bucket_versioning_enabled" + ] + }, + { + "Id": "18.1", + "Description": "Toutes les exigences legales, reglementaires et contractuelles en vigueur, ainsi que l'approche du prestataire pour satisfaire ces exigences, doivent etre explicitement definies, documentees et tenues a jour pour chaque systeme d'information et pour le prestataire.", + "Name": "Identification de la legislation et des exigences contractuelles applicables", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit identifier les exigences legales, reglementaires et contractuelles en vigueur applicables au service. En France, le prestataire doit considerer au minimum les textes suivants : les donnees a caractere personnel [LOI_IL], [RGPD] ; le secret professionnel [CP_ART_226_13], le cas echeant sans prejudice de l'application de l'article 40 alinea 2 du Code de procedure penale relatif au signalement a une autorite judiciaire ; l'abus de confiance [CP_ART_314-1] ; le secret des correspondances privees [CP_ART_226-15] ; l'atteinte a la vie privee [CP_ART_226-1] ; l'acces ou le maintien frauduleux a un systeme d'information [CP_ART_323-1]. b) Le prestataire doit, selon son role dans les traitements de donnees a caractere personnel (responsable de traitement, sous-traitant ou co-responsable) justifier et documenter les choix de mesures techniques et organisationnelles realises en vue de repondre aux exigences de protection des donnees a caractere personnel du present referentiel (voir partie 19.5). c) Le prestataire doit documenter et mettre en oeuvre les procedures permettant de respecter les exigences legales, reglementaires et contractuelles en vigueur applicables au service, ainsi que les besoins de securite specifiques (voir exigence 8.3b)). d) Le prestataire doit, sur demande d'un commanditaire, lui rendre accessible l'ensemble de ces procedures. e) Le prestataire doit documenter et mettre en oeuvre un processus de veille actif des exigences legales, reglementaires et contractuelles en vigueur applicables au service." + } + ], + "Checks": [] + }, + { + "Id": "18.2", + "Description": "L'approche du prestataire vis-a-vis de la gestion de la securite de l'information et sa mise en oeuvre (c'est-a-dire les objectifs de controle, les mesures, les politiques, les procedures et les processus relatifs a la securite de l'information) doivent etre revues de maniere independante a intervalles definis ou en cas de changement significatif.", + "Name": "Revue independante de la securite de l'information", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre un programme d'audit sur trois ans definissant le perimetre et la frequence des audits en accord avec la gestion du changement, les politiques, et les resultats de l'appreciation des risques. Le prestataire doit inclure dans le programme d'audit un audit qualifie par an realise par un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie. L'ensemble du programme d'audit doit notamment couvrir : l'audit de la configuration de l'infrastructure technique du service (par echantillonnage et doit inclure tous types d'equipements et de serveurs presents dans le systeme d'information du service) ; le test d'intrusion des interfaces d'administration exposees sur un reseau public ; le test d'intrusion de l'interface utilisateur pour les services SaaS ; si le service beneficie de developpements internes, l'audit de code source portant sur les fonctionnalites de securite implementees (l'approche en continue doit etre privilegiee). b) Il est recommande que le prestataire mette en oeuvre des mecanismes automatises d'audit de la configuration adaptes a l'infrastructure technique du service." + } + ], + "Checks": [] + }, + { + "Id": "18.3", + "Description": "Les responsables doivent regulierement s'assurer de la conformite du traitement de l'information et des procedures au sein de leur domaine de responsabilite, au regard des politiques et des normes de securite.", + "Name": "Conformite avec les politiques et les normes de securite", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire via le responsable de la securite de l'information doit s'assurer regulierement de l'execution correcte de l'ensemble des procedures de securite placees sous sa responsabilite en vue de garantir leur conformite avec les politiques et normes de securite." + } + ], + "Checks": [ + "cloudguard_enabled" + ] + }, + { + "Id": "18.4", + "Description": "Les systemes d'information doivent etre examines regulierement quant a leur conformite avec les politiques et les normes de securite de l'information du prestataire.", + "Name": "Examen de la conformite technique", + "Attributes": [ + { + "Section": "18. Conformite", + "Service": "cloudguard", + "Type": "Partially Automated", + "Comment": "a) Le prestataire doit documenter et mettre en oeuvre une politique permettant de verifier la conformite technique du service aux exigences du present referentiel. Cette politique doit definir les objectifs, methodes, frequences, resultats attendus et mesures correctrices." + } + ], + "Checks": [ + "cloudguard_enabled" + ] + }, + { + "Id": "19.1", + "Description": "Le prestataire doit etablir une convention de service avec le commanditaire definissant les engagements de niveau de service, les responsabilites et les conditions d'utilisation du service cloud.", + "Name": "Convention de service", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit etablir une convention de service avec chacun des commanditaires du service. Toute modification de la convention de service doit etre soumise a acceptation du commanditaire. b) Le prestataire doit identifier dans la convention de service : les obligations, droits et responsabilites de chacune des parties : prestataire et tiers impliques dans la fourniture du service, commanditaires, etc. ; les elements explicitement exclus des responsabilites du prestataire dans la limite de ce que prevoient les exigences legales et reglementaires en vigueur, notamment l'article 28 du [RGPD] ; la localisation du service. La localisation du support doit etre precisee lorsqu'il est realise depuis un Etat hors l'Union Europeenne, comme le permet l'exigence 19.2.e. c) Le prestataire doit proposer une convention de service appliquant le droit d'un Etat membre de l'Union Europeenne. Le droit applicable doit etre identifie dans la convention de service. d) La convention de service doit indiquer que la collecte, la manipulation, le stockage, et plus generalement le traitement des donnees faits dans le cadre de l'avant-vente, de la mise en oeuvre, de la maintenance et l'arret du service sont realises conformement aux exigences edictees par la legislation en vigueur. e) La convention de service doit indiquer que le prestataire doit mettre a la disposition du commanditaire, sur demande de celui-ci, les elements d'appreciation des risques lies a la soumission des donnees du commanditaire au droit d'un etat non-membre de l'Union Europeenne (voir 5.3.e). f) Le prestataire doit decrire dans la convention de service les moyens techniques et organisationnels qu'il met en oeuvre pour assurer le respect du droit applicable. g) Le prestataire doit inclure dans la convention de service une clause de revision de la convention prevoyant notamment une resiliation sans penalite pour le commanditaire en cas de perte de la qualification octroyee au service. h) Le prestataire doit inclure dans la convention de service une clause de reversibilite permettant au commanditaire de recuperer l'ensemble de ses donnees (fournies directement par le commanditaire ou produites dans le cadre du service a partir des donnees ou des actions du commanditaire). i) Le prestataire doit assurer cette reversibilite via l'une des modalites techniques suivantes : la mise a disposition de fichiers suivant un ou plusieurs formats documentes et exploitables en dehors du service fourni par le prestataire ; la mise en place d'interfaces techniques permettant l'acces aux donnees suivant un schema documente et exploitable (API, format pivot, etc.). Les modalites techniques de la reversibilite figurent dans la convention de service. j) Le prestataire doit indiquer dans la convention de service le niveau de disponibilite du service. k) Le prestataire doit indiquer dans la convention de service qu'il ne peut disposer des donnees transmises et generees par le commanditaire, leur disposition etant reservee au commanditaire. l) Le prestataire doit indiquer dans la convention de service qu'il ne divulgue aucune information relative a la prestation a des tiers, sauf autorisation formelle et ecrite du commanditaire. m) Le prestataire doit indiquer dans la convention de service si les donnees du commanditaire sont automatiquement sauvegardees ou non. Dans la negative, le prestataire doit sensibiliser le commanditaire aux risques encourus et clairement indiquer les operations a mener par le commanditaire pour que ses donnees soient sauvegardees. n) Le prestataire doit indiquer dans la convention de service s'il autorise l'acces distant pour des actions d'administration ou de support au systeme d'information du service. o) Le prestataire doit preciser dans la convention de service que : le service est qualifie et inclure l'attestation de qualification ; le commanditaire peut deposer une reclamation relative au service qualifie aupres de l'ANSSI ; le commanditaire autorise l'ANSSI et l'organisme de qualification a auditer le service et son systeme d'information du service afin de verifier qu'ils respectent les exigences du present referentiel. p) Le prestataire doit preciser dans la convention de service que le commanditaire autorise, conformement au present referentiel (voir chapitre 18.2, un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie mandate par le prestataire a auditer le service et son systeme d'information dans le cadre du plan de controle. q) Le prestataire doit preciser dans la convention de service qu'il s'engage a mettre a disposition toutes les informations necessaires a la realisation d'audits de conformite aux dispositions de l'article 28 du [RGPD], menes par le commanditaire ou un tiers mandate. r) Il est recommande que le tiers mandate pour les audits soit un prestataire d'audit de la securite des systemes d'information [PASSI] qualifie." + } + ], + "Checks": [] + }, + { + "Id": "19.2", + "Description": "Les donnees du commanditaire doivent etre stockees et traitees dans des centres de donnees situes sur le territoire de l'Union europeenne. Les politiques de restriction de region doivent etre appliquees.", + "Name": "Localisation des donnees", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit documenter et communiquer au commanditaire la localisation du stockage et du traitement des donnees de ce dernier. b) Le prestataire doit stocker et traiter les donnees du commanditaire au sein de l'Union Europeenne. c) Les operations d'administration et de supervision du service doivent etre realisees depuis le territoire de l'Union Europeenne. d) Le prestataire doit stocker et traiter les donnees techniques (identites des beneficiaires et des administrateurs de l'infrastructure technique, donnees manipulees par le Software Defined Network, journaux de l'infrastructure technique, annuaire, certificats, configuration des acces, etc.) au sein de l'Union Europeenne. e) Le prestataire peut realiser des operations de support aux commanditaires depuis un Etat hors de l'Union Europeenne. Il doit documenter la liste des operations qui peuvent etre effectuees par le support au commanditaire depuis un Etat hors de l'Union Europeenne, et les mecanismes permettant d'en assurer le controle d'acces et la supervision depuis l'Union Europeenne." + } + ], + "Checks": [] + }, + { + "Id": "19.3", + "Description": "Les services cloud qualifies SecNumCloud doivent etre operes depuis le territoire de l'Union europeenne.", + "Name": "Regionalisation", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit s'assurer que les interfaces du service accessibles au commanditaire soient au moins disponibles en langue francaise. b) Le prestataire doit fournir un support de premier niveau en langue francaise." + } + ], + "Checks": [] + }, + { + "Id": "19.4", + "Description": "Le prestataire doit definir les conditions de fin de contrat, incluant les modalites de restitution et de suppression des donnees du commanditaire.", + "Name": "Fin de contrat", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) A la fin du contrat liant le prestataire et le commanditaire, que le contrat soit arrive a son terme ou pour toute autre cause, le prestataire doit assurer un effacement securise de l'integralite des donnees du commanditaire. Cet effacement doit faire l'objet d'un preavis formel au commanditaire de la part du prestataire respectant un delai de vingt et un jours calendaires. L'effacement peut etre realise suivant l'une des methodes suivantes, et ce dans un delai precise dans la convention de service : effacement par reecriture complete de tout support ayant heberge ces donnees ; effacement des cles utilisees pour le chiffrement des espaces de stockage du commanditaire decrit au chapitre 10.1 ; recyclage securise, dans les conditions enoncees au chapitre 11.9. b) A la fin du contrat, le prestataire doit supprimer les donnees techniques relatives au commanditaire (annuaire, certificats, configuration des acces, etc.)." + } + ], + "Checks": [] + }, + { + "Id": "19.5", + "Description": "Le prestataire doit mettre en oeuvre des mesures techniques et organisationnelles appropriees pour garantir la protection des donnees a caractere personnel conformement a la reglementation en vigueur.", + "Name": "Protection des donnees a caractere personnel", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le prestataire doit justifier du respect des principes de protection des donnees pour les traitements de donnees a caractere personnel mis en oeuvre pour son propre compte. Il doit justifier au minimum les points suivants : les finalites des traitements determinees, explicites et legitimes ; la tracabilite des activites de traitement pour son compte et celui de son commanditaire ; le fondement licite des traitements ; l'interdiction du detournement de finalite des traitements ; les donnees utilisees respectent le principe du minimum necessaire et suffisant pour les traitements ; ainsi sont adequates, pertinentes et limitees ; la qualite des donnees utilisees pour les traitements maintenue : donnees exactes et tenues a jour ; les durees de conservation definies et limitees. b) Le prestataire doit justifier, pour les traitements de donnees a caractere personnel mis en oeuvre pour son propre compte, du respect des droits des personnes concernees. Il doit justifier au minimum les points suivants : l'information des usagers via un traitement loyal et transparent ; le recueil du consentement des usagers : expres, demontrable et retirable ; la possibilite pour les usagers d'exercer les droits d'acces, de rectification et d'effacement ; la possibilite pour les usagers d'exercer les droits de limitation du traitement, de portabilite et d'opposition. c) Lorsqu'il agit en qualite de sous-traitant au sens de l'article 28 de [RGPD], le prestataire doit apporter assistance et conseil au commanditaire en l'informant si une instruction de ce dernier constitue une violation des regles de protection des donnees." + } + ], + "Checks": [] + }, + { + "Id": "19.6", + "Description": "Le prestataire doit mettre en oeuvre des mesures de protection vis-a-vis du droit extra-europeen, afin de garantir que les donnees du commanditaire ne puissent etre soumises a des legislations extra-europeennes.", + "Name": "Protection vis-a-vis du droit extra-europeen", + "Attributes": [ + { + "Section": "19. Exigences supplementaires", + "Service": "general", + "Type": "Manual", + "Comment": "a) Le siege statutaire, administration centrale et principal etablissement du prestataire doivent etre etablis au sein d'un Etat membre de l'Union Europeenne. b) Le capital social et les droits de vote dans la societe du prestataire ne doivent pas etre, directement ou indirectement : individuellement detenus a plus de 24% ; et collectivement detenus a plus de 39% ; par des entites tierces possedant leur siege statutaire, administration centrale ou principal etablissement au sein d'un Etat non membre de l'Union europeenne. Ces entites tierces susmentionnees ne peuvent pas individuellement ou collectivement : en vertu d'un contrat ou de clauses statutaires, disposer d'un droit de veto ; en vertu d'un contrat ou de clauses statutaires, designer la majorite des membres des organes d'administration, de direction ou de surveillance du prestataire. c) En cas de recours par le prestataire, dans le cadre des services fournis au commanditaire, aux services d'une societe tierce - y compris un sous-traitant - possedant son siege statutaire, administration centrale ou principal etablissement au sein d'un Etat non membre de l'Union Europeenne ou appartenant ou etant controlee par une societe tierce domiciliee en dehors l'Union Europeenne, cette susdite societe tierce ne doit pas avoir la possibilite technique d'obtenir les donnees operees au travers du service. d) Dans le cadre de l'exigence 19.6.c, toute societe tierce a laquelle le prestataire recourt pour fournir tout ou partie du service rendu au commanditaire, doit garantir au prestataire une autonomie d'exploitation continue dans la fourniture des services d'informatique en nuage qu'il opere ou doit etre qualifie SecNumCloud. e) Le service fourni par le prestataire doit respecter la legislation en vigueur en matiere de droits fondamentaux et les valeurs de l'Union relatives au respect de la dignite humaine, a la liberte, a l'egalite, a la democratie et a l'Etat de droit. f) Le prestataire doit informer formellement le commanditaire, et dans un delai d'un mois, de tout changement juridique, organisationnel ou technique pouvant avoir un impact sur la conformite de la prestation aux exigences du chapitre 19.6." + } + ], + "Checks": [] + } + ] +} diff --git a/prowler/config/config.py b/prowler/config/config.py index 7624ab65bf..3339a0d1ea 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -38,7 +38,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.19.0" +prowler_version = "5.23.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" @@ -65,6 +65,7 @@ class Provider(str, Enum): ALIBABACLOUD = "alibabacloud" OPENSTACK = "openstack" IMAGE = "image" + VERCEL = "vercel" # Providers that delegate scanning to an external tool (e.g. Trivy, promptfoo) @@ -122,8 +123,8 @@ encoding_format_utf_8 = "utf-8" available_output_formats = ["csv", "json-asff", "json-ocsf", "html"] # Prowler Cloud API settings -cloud_api_base_url = os.getenv("PROWLER_CLOUD_API_BASE", "https://api.prowler.com") -cloud_api_key = os.getenv("PROWLER_API_KEY", "") +cloud_api_base_url = os.getenv("PROWLER_CLOUD_API_BASE_URL", "https://api.prowler.com") +cloud_api_key = os.getenv("PROWLER_CLOUD_API_KEY", "") cloud_api_ingestion_path = "/api/v1/ingestions" diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 4682afc7af..c645d6e25b 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -72,6 +72,11 @@ aws: # trusted_account_ids : ["123456789012", "098765432109", "678901234567"] trusted_account_ids: [] + # AWS OpenSearch Configuration (opensearch_service_domains_not_publicly_accessible) + # Trusted IP addresses or CIDR ranges that should not be considered as public access, e.g. + # trusted_ips: ["1.2.3.4", "10.0.0.0/8"] + trusted_ips: [] + # AWS Cloudwatch Configuration # aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days log_group_retention_days: 365 @@ -604,3 +609,34 @@ cloudflare: # Maximum number of retries for API requests (default is 2) # Set to 0 to disable retries max_retries: 3 + +# Vercel Configuration +vercel: + # vercel.deployment_production_uses_stable_target + # Branches considered stable for production deployments + stable_branches: + - "main" + - "master" + # vercel.authentication_token_not_expired & vercel.domain_ssl_certificate_valid + # Number of days before expiration to flag a token/certificate as about to expire + days_to_expire_threshold: 7 + # vercel.authentication_no_stale_tokens + # Number of days of inactivity before a token is considered stale + stale_token_threshold_days: 90 + # vercel.team_no_stale_invitations + # Number of days before a pending invitation is considered stale + stale_invitation_threshold_days: 30 + # vercel.team_member_role_least_privilege + # Maximum percentage of team members that can have the OWNER role + max_owner_percentage: 20 + # Maximum number of owners allowed (overrides percentage for large teams) + max_owners: 3 + # vercel.project_environment_no_secrets_in_plain_type + # Suffixes that identify secret-like environment variable names + secret_suffixes: + - "_KEY" + - "_SECRET" + - "_TOKEN" + - "_PASSWORD" + - "_API_KEY" + - "_PRIVATE_KEY" diff --git a/prowler/config/oraclecloud_mutelist_example.yaml b/prowler/config/oraclecloud_mutelist_example.yaml index 3e40310ea8..2f56167b6a 100644 --- a/prowler/config/oraclecloud_mutelist_example.yaml +++ b/prowler/config/oraclecloud_mutelist_example.yaml @@ -1,12 +1,12 @@ -### Tenancy, Check and/or Region can be * to apply for all the cases. -### Tenancy == OCI Tenancy OCID and Region == OCI Region +### Account, Check and/or Region can be * to apply for all the cases. +### Account == OCI Tenancy OCID and Region == OCI Region ### Resources and tags are lists that can have either Regex or Keywords. ### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. ### Use an alternation Regex to match one of multiple tags with "ORed" logic. -### For each check you can except Tenancies, Regions, Resources and/or Tags. +### For each check you can except Accounts, Regions, Resources and/or Tags. ########################### MUTELIST EXAMPLE ########################### Mutelist: - Tenancies: + Accounts: "ocid1.tenancy.oc1..aaaaaaaexample": Checks: "iam_user_mfa_enabled": diff --git a/prowler/config/vercel_mutelist_example.yaml b/prowler/config/vercel_mutelist_example.yaml new file mode 100644 index 0000000000..ee6439b77f --- /dev/null +++ b/prowler/config/vercel_mutelist_example.yaml @@ -0,0 +1,50 @@ +### Account, Check and/or Region can be * to apply for all the cases. +### Account == +### Region == * (Vercel is a global service, region is always "global") +### Resources and tags are lists that can have either Regex or Keywords. +### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together. +### Use an alternation Regex to match one of multiple tags with "ORed" logic. +### For each check you can except Accounts, Regions, Resources and/or Tags. +########################### MUTELIST EXAMPLE ########################### +Mutelist: + Accounts: + "team_example123": + Checks: + "project_deployment_protection_enabled": + Regions: + - "*" + Resources: + - "prj_internal001" + - "prj_internal002" + Description: "Mute deployment protection check for internal-only projects" + "project_environment_*": + Regions: + - "*" + Resources: + - "prj_staging.*" + Description: "Mute all environment variable checks for staging projects" + "*": + Regions: + - "*" + Resources: + - "prj_sandbox" + Tags: + - "environment=sandbox" + Description: "Mute all checks for sandbox project with matching tag" + + "*": + Checks: + "security_waf_enabled": + Regions: + - "*" + Resources: + - "prj_static.*" + Description: "Mute WAF check for static-only projects across all teams" + "*": + Regions: + - "*" + Resources: + - "*" + Tags: + - "prowler-ignore=true" + Description: "Global mute for resources tagged with prowler-ignore=true" diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index b09595f219..8e365acedc 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -228,6 +228,28 @@ def print_categories(categories: set): print(message) +def list_resource_groups(bulk_checks_metadata: dict) -> set: + available_resource_groups = set() + for check in bulk_checks_metadata.values(): + if check.ResourceGroup: + available_resource_groups.add(check.ResourceGroup) + return available_resource_groups + + +def print_resource_groups(resource_groups: set): + rg_num = len(resource_groups) + plural_string = f"\nThere are {Fore.YELLOW}{rg_num}{Style.RESET_ALL} available resource groups.\n" + singular_string = ( + f"\nThere is {Fore.YELLOW}{rg_num}{Style.RESET_ALL} available resource group.\n" + ) + + message = plural_string if rg_num > 1 else singular_string + for rg in sorted(resource_groups): + print(f"- {rg}") + + print(message) + + def print_services(service_list: set): services_num = len(service_list) plural_string = f"\nThere are {Fore.YELLOW}{services_num}{Style.RESET_ALL} available services.\n" @@ -691,6 +713,15 @@ def execute( is_finding_muted_args["project_id"] = ( global_provider.identity.project_id ) + elif global_provider.type == "vercel": + team = getattr(global_provider.identity, "team", None) + is_finding_muted_args["team_id"] = ( + team.id if team else global_provider.identity.user_id + ) + elif global_provider.type == "oraclecloud": + is_finding_muted_args["tenancy_id"] = ( + global_provider.identity.tenancy_id + ) for finding in check_findings: if global_provider.type == "cloudflare": is_finding_muted_args["account_id"] = finding.account_id diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index bc1ea937be..366f1ed9d6 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -19,6 +19,8 @@ def load_checks_to_execute( severities: list = None, compliance_frameworks: list = None, categories: set = None, + resource_groups: set = None, + list_checks: bool = False, ) -> set: """Generate the list of checks to execute based on the cloud provider and the input arguments given""" try: @@ -30,6 +32,7 @@ def load_checks_to_execute( checks_to_execute = set() check_aliases = {} check_categories = {} + check_resource_groups = {} check_severities = {severity.value: [] for severity in Severity} if not bulk_checks_metadata: @@ -52,6 +55,13 @@ def load_checks_to_execute( if category not in check_categories: check_categories[category] = [] check_categories[category].append(check) + + # Resource Groups (stored lowercase for case-insensitive matching) + if metadata.ResourceGroup: + rg_key = metadata.ResourceGroup.lower() + if rg_key not in check_resource_groups: + check_resource_groups[rg_key] = [] + check_resource_groups[rg_key].append(check) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" @@ -170,6 +180,28 @@ def load_checks_to_execute( for category in categories: checks_to_execute.update(check_categories[category]) + # Handle if there are resource groups passed using --resource-group + elif resource_groups: + # Validate that all resource groups exist (case-insensitive) + available_resource_groups = set(check_resource_groups.keys()) + normalized_resource_groups = [rg.lower() for rg in resource_groups] + invalid_resource_groups = [ + rg + for rg in normalized_resource_groups + if rg not in available_resource_groups + ] + if invalid_resource_groups: + logger.critical( + f"Invalid resource group(s) specified: {', '.join(invalid_resource_groups)}" + ) + logger.critical( + f"Please provide valid resource group names. Use 'prowler {provider} --list-resource-groups' to see available resource groups." + ) + sys.exit(1) + + for resource_group in normalized_resource_groups: + checks_to_execute.update(check_resource_groups[resource_group]) + # If there are no checks passed as argument else: # get all checks @@ -178,7 +210,12 @@ def load_checks_to_execute( ): checks_to_execute.add(check_name) # Only execute threat detection checks if threat-detection category is set - if (not categories or "threat-detection" not in categories) and not check_list: + # Skip this exclusion when listing checks (--list-checks or --list-checks-json) + if ( + (not categories or "threat-detection" not in categories) + and not check_list + and not list_checks + ): for threat_detection_check in check_categories.get("threat-detection", []): checks_to_execute.discard(threat_detection_check) diff --git a/prowler/lib/check/custom_checks_metadata.py b/prowler/lib/check/custom_checks_metadata.py index 3c100a720b..29fe64a7b8 100644 --- a/prowler/lib/check/custom_checks_metadata.py +++ b/prowler/lib/check/custom_checks_metadata.py @@ -112,24 +112,22 @@ def update_checks_metadata(bulk_checks_metadata, custom_checks_metadata): def update_check_metadata(check_metadata, custom_metadata): """update_check_metadata updates the check_metadata fields present in the custom_metadata and returns the updated version of the check_metadata. If some field is not present or valid the check_metadata is returned with the original fields.""" - try: - if custom_metadata: - for attribute in custom_metadata: - if attribute == "Remediation": - for remediation_attribute in custom_metadata[attribute]: - update_check_metadata_remediation( - check_metadata, - custom_metadata, - attribute, - remediation_attribute, - ) - else: - try: - setattr(check_metadata, attribute, custom_metadata[attribute]) - except ValueError: - pass - finally: - return check_metadata + if custom_metadata: + for attribute in custom_metadata: + if attribute == "Remediation": + for remediation_attribute in custom_metadata[attribute]: + update_check_metadata_remediation( + check_metadata, + custom_metadata, + attribute, + remediation_attribute, + ) + else: + try: + setattr(check_metadata, attribute, custom_metadata[attribute]) + except ValueError: + pass + return check_metadata def update_check_metadata_remediation( diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 5b514206ec..43d6018cbf 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -1,4 +1,5 @@ import functools +import json import os import re import sys @@ -15,6 +16,115 @@ from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.utils import recover_checks_from_provider from prowler.lib.logger import logger +# Valid ResourceGroup values as defined in the RFC +VALID_RESOURCE_GROUPS = frozenset( + { + "compute", + "container", + "serverless", + "database", + "storage", + "network", + "IAM", + "messaging", + "security", + "monitoring", + "api_gateway", + "ai_ml", + "governance", + "collaboration", + "devops", + "analytics", + } +) + +# Valid Categories as defined in the RFC +VALID_CATEGORIES = frozenset( + { + "encryption", + "internet-exposed", + "logging", + "secrets", + "resilience", + "threat-detection", + "trust-boundaries", + "vulnerabilities", + "cluster-security", + "container-security", + "node-security", + "gen-ai", + "ci-cd", + "identity-access", + "email-security", + "forensics-ready", + "software-supply-chain", + "e3", + "e5", + "privilege-escalation", + "ec2-imdsv1", + } +) + + +@functools.lru_cache(maxsize=1) +def _load_aws_check_types_hierarchy() -> dict: + """ + Load and cache the AWS CheckTypes hierarchy from the JSON config file. + + Returns: + dict: The CheckTypes hierarchy, or empty dict if file not found. + """ + try: + current_dir = os.path.dirname(os.path.abspath(__file__)) + check_types_file = os.path.normpath( + os.path.join( + current_dir, + "..", + "..", + "providers", + "aws", + "config", + "check_types.json", + ) + ) + + if not os.path.exists(check_types_file): + return {} + + with open(check_types_file, "r") as f: + return json.load(f) + + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _validate_aws_check_type_in_config(check_type: str) -> bool: + """ + Validate if a CheckType exists in the AWS config using direct lookups. + Supports partial paths: namespace, namespace/category, namespace/category/classifier + + Args: + check_type: The CheckType string to validate (e.g., "TTPs/Initial Access") + + Returns: + bool: True if the CheckType path exists in the config hierarchy + """ + if not check_type: + return False + + hierarchy = _load_aws_check_types_hierarchy() + if not hierarchy: + return False + + path_parts = check_type.split("/") + current_level = hierarchy + for part in path_parts: + if not isinstance(current_level, dict) or part not in current_level: + return False + current_level = current_level[part] + + return True + class Code(BaseModel): """ @@ -94,11 +204,19 @@ class CheckMetadata(BaseModel): Compliance (list, optional): The compliance information for the check. Defaults to None. Validators: - valid_category(value): Validator function to validate the categories of the check. + valid_category(value): Validator function to validate the categories of the check against predefined values. severity_to_lower(severity): Validator function to convert the severity to lowercase. - valid_severity(severity): Validator function to validate the severity of the check. valid_cli_command(remediation): Validator function to validate the CLI command is not an URL. valid_resource_type(resource_type): Validator function to validate the resource type is not empty. + validate_service_name(service_name, values): Validator function to validate the service name matches CheckID. + valid_check_id(check_id): Validator function to validate the CheckID format. + validate_check_title(check_title): Validator function to validate CheckTitle max length (150 chars) and not starting with 'Ensure'. + validate_related_url(related_url): Validator function to validate RelatedUrl is empty (deprecated field). + validate_recommendation_url(remediation): Validator function to validate Recommendation URL points to Prowler Hub. + validate_check_type(check_type, values): Validator function to validate CheckType - must be empty for non-AWS providers, no empty strings and predefined types validation for AWS. + validate_description(description): Validator function to validate Description max length (400 chars). + validate_risk(risk): Validator function to validate Risk max length (400 chars). + validate_resource_group(resource_group): Validator function to validate ResourceGroup against predefined values. validate_additional_urls(additional_urls): Validator function to ensure AdditionalURLs contains no duplicates. """ @@ -127,7 +245,7 @@ class CheckMetadata(BaseModel): Compliance: Optional[list[Any]] = Field(default_factory=list) @validator("Categories", each_item=True, pre=True, always=True) - def valid_category(value): + def valid_category(cls, value, values): if not isinstance(value, str): raise ValueError("Categories must be a list of strings") value_lower = value.lower() @@ -135,6 +253,13 @@ class CheckMetadata(BaseModel): raise ValueError( f"Invalid category: {value}. Categories can only contain lowercase letters, numbers and hyphen '-'" ) + if ( + value_lower not in VALID_CATEGORIES + and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS + ): + raise ValueError( + f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}." + ) return value_lower @validator("Severity", pre=True, always=True) @@ -183,6 +308,90 @@ class CheckMetadata(BaseModel): return check_id + @validator("CheckTitle", pre=True, always=True) + def validate_check_title(cls, check_title, values): + if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if len(check_title) > 150: + raise ValueError( + f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters" + ) + if check_title.startswith("Ensure"): + raise ValueError( + "CheckTitle must not start with 'Ensure'. Use a descriptive title that focuses on the security state." + ) + return check_title + + @validator("RelatedUrl", pre=True, always=True) + def validate_related_url(cls, related_url, values): + if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + raise ValueError("RelatedUrl must be empty. This field is deprecated.") + return related_url + + @validator("Remediation") + def validate_recommendation_url(cls, remediation, values): + if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + url = remediation.Recommendation.Url + if url and not url.startswith("https://hub.prowler.com/"): + raise ValueError( + f"Remediation Recommendation URL must point to Prowler Hub (https://hub.prowler.com/...), got '{url}'." + ) + return remediation + + @validator("CheckType", pre=True, always=True) + def validate_check_type(cls, check_type, values): + provider = values.get("Provider", "").lower() + + # Non-AWS providers must have an empty CheckType list + if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS: + if check_type: + raise ValueError( + f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'." + ) + return check_type + + # Check for empty strings in the list - applies to AWS + for i, check_type_item in enumerate(check_type): + if not check_type_item or check_type_item.strip() == "": + raise ValueError( + f"CheckType list cannot contain empty strings. Found empty string at index {i}." + ) + + # For AWS provider, validate against config hierarchy + if provider == "aws": + for check_type_item in check_type: + if not _validate_aws_check_type_in_config(check_type_item): + raise ValueError( + f"Invalid CheckType: '{check_type_item}'. Must be a valid path in the AWS CheckType hierarchy. See prowler/providers/aws/config/check_types.json for valid values." + ) + + return check_type + + @validator("Description", pre=True, always=True) + def validate_description(cls, description, values): + if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if len(description) > 400: + raise ValueError( + f"Description must not exceed 400 characters, got {len(description)} characters" + ) + return description + + @validator("Risk", pre=True, always=True) + def validate_risk(cls, risk, values): + if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + if len(risk) > 400: + raise ValueError( + f"Risk must not exceed 400 characters, got {len(risk)} characters" + ) + return risk + + @validator("ResourceGroup", pre=True, always=True) + def validate_resource_group(cls, resource_group): + if resource_group and resource_group not in VALID_RESOURCE_GROUPS: + raise ValueError( + f"Invalid ResourceGroup: '{resource_group}'. Must be one of: {', '.join(sorted(VALID_RESOURCE_GROUPS))} or empty string." + ) + return resource_group + @validator("AdditionalURLs", pre=True, always=True) def validate_additional_urls(cls, additional_urls): if not isinstance(additional_urls, list): @@ -818,11 +1027,11 @@ class CheckReportCloudflare(Check_Report): @property def account_id(self) -> str: - """Account ID derived from zone's account.""" + """Account ID derived from resource's account object or flat account_id.""" zone_account = getattr(self._zone, "account", None) if zone_account: return getattr(zone_account, "id", "") - return "" + return getattr(self._zone, "account_id", "") @property def region(self) -> str: @@ -1031,6 +1240,50 @@ class CheckReportMongoDBAtlas(Check_Report): self.location = getattr(resource, "location", self.project_id) +@dataclass +class CheckReportVercel(Check_Report): + """Contains the Vercel Check's finding information. + + Vercel is a global platform - team_id is the scoping context. + All resource-related attributes are derived from the resource object. + """ + + resource_name: str + resource_id: str + team_id: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str = None, + resource_id: str = None, + team_id: str = None, + ) -> None: + """Initialize the Vercel Check's finding information. + + Args: + metadata: Check metadata dictionary + resource: The Vercel resource being checked + resource_name: Override for resource name + resource_id: Override for resource ID + team_id: Override for team ID + """ + super().__init__(metadata, resource) + self.resource_name = resource_name or getattr( + resource, "name", getattr(resource, "resource_name", "") + ) + self.resource_id = resource_id or getattr( + resource, "id", getattr(resource, "resource_id", "") + ) + self.team_id = team_id or getattr(resource, "team_id", "") + + @property + def region(self) -> str: + """Vercel is global - return 'global'.""" + return "global" + + # Testing Pending def load_check_metadata(metadata_file: str) -> CheckMetadata: """ diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 6d3ad007b6..47275b12cf 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -19,6 +19,8 @@ from prowler.providers.common.arguments import ( validate_provider_arguments, ) +SENSITIVE_ARGUMENTS = frozenset({"--shodan"}) + class ProwlerArgumentParser: # Set the default parser @@ -27,10 +29,10 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ...", + usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image} ...", epilog=""" Available Cloud Providers: - {aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack} + {aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -47,6 +49,7 @@ Available Cloud Providers: image Container Image Provider nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider (Beta) + vercel Vercel Provider Available components: dashboard Local dashboard @@ -217,12 +220,13 @@ Detailed documentation at https://docs.prowler.com help="Set the output timestamp format as unix timestamps instead of iso format timestamps (default mode).", ) common_outputs_parser.add_argument( - "--export-ocsf", + "--push-to-cloud", action="store_true", help=( - "Send OCSF output to Prowler Cloud ingestion endpoint. " - "Requires PROWLER_API_KEY environment variable. " - "For the IaC provider, --provider-uid is also required." + "Send findings in OCSF format to Prowler Cloud. " + "Requires PROWLER_CLOUD_API_KEY environment variable. " + "For the IaC provider, --provider-uid is also required. " + "More details here: https://goto.prowler.com/import-findings" ), ) @@ -321,6 +325,13 @@ Detailed documentation at https://docs.prowler.com default=[], # TODO: Pending validate choices ) + group.add_argument( + "--resource-group", + "--resource-groups", + nargs="+", + help="List of resource groups to be executed.", + default=[], + ) common_checks_parser.add_argument( "--checks-folder", "-x", @@ -331,7 +342,7 @@ Detailed documentation at https://docs.prowler.com def __init_list_checks_parser__(self): # List checks options list_checks_parser = self.common_providers_parser.add_argument_group( - "List checks/services/categories/compliance-framework checks" + "List checks/services/categories/resource-groups/compliance-framework checks" ) list_group = list_checks_parser.add_mutually_exclusive_group() list_group.add_argument( @@ -364,6 +375,11 @@ Detailed documentation at https://docs.prowler.com action="store_true", help="List the available check's categories", ) + list_group.add_argument( + "--list-resource-groups", + action="store_true", + help="List the available check's resource groups", + ) list_group.add_argument( "--list-fixer", "--list-fixers", diff --git a/prowler/lib/cli/redact.py b/prowler/lib/cli/redact.py new file mode 100644 index 0000000000..2dfd9e2bfa --- /dev/null +++ b/prowler/lib/cli/redact.py @@ -0,0 +1,68 @@ +from functools import lru_cache +from importlib import import_module + +from prowler.lib.logger import logger +from prowler.providers.common.provider import Provider, providers_path + +REDACTED_VALUE = "REDACTED" + + +@lru_cache(maxsize=None) +def get_sensitive_arguments() -> frozenset: + """Collect SENSITIVE_ARGUMENTS from all provider argument modules and the common parser.""" + sensitive: set[str] = set() + + # Common parser sensitive arguments (e.g., --shodan) + try: + parser_module = import_module("prowler.lib.cli.parser") + sensitive.update(getattr(parser_module, "SENSITIVE_ARGUMENTS", frozenset())) + except Exception as error: + logger.debug(f"Could not load SENSITIVE_ARGUMENTS from parser: {error}") + + # Provider-specific sensitive arguments + for provider in Provider.get_available_providers(): + try: + module = import_module( + f"{providers_path}.{provider}.lib.arguments.arguments" + ) + sensitive.update(getattr(module, "SENSITIVE_ARGUMENTS", frozenset())) + except Exception as error: + logger.debug(f"Could not load SENSITIVE_ARGUMENTS from {provider}: {error}") + + return frozenset(sensitive) + + +def redact_argv(argv: list[str]) -> str: + """Redact values of sensitive CLI flags from an argument list. + + Handles both ``--flag value`` and ``--flag=value`` syntax. + Returns a single joined string suitable for display. + """ + sensitive = get_sensitive_arguments() + result: list[str] = [] + skip_next = False + + for i, arg in enumerate(argv): + if skip_next: + result.append(REDACTED_VALUE) + skip_next = False + continue + + # Handle --flag=value syntax + if "=" in arg: + flag = arg.split("=", 1)[0] + if flag in sensitive: + result.append(f"{flag}={REDACTED_VALUE}") + continue + + # Handle --flag value syntax + if arg in sensitive: + result.append(arg) + # Only redact the next token if it exists and is not another flag + if i + 1 < len(argv) and not argv[i + 1].startswith("-"): + skip_next = True + continue + + result.append(arg) + + return " ".join(result) diff --git a/prowler/lib/outputs/asff/asff.py b/prowler/lib/outputs/asff/asff.py index a5216b36ee..ab6f2fe582 100644 --- a/prowler/lib/outputs/asff/asff.py +++ b/prowler/lib/outputs/asff/asff.py @@ -76,6 +76,8 @@ class ASFF(Output): ProductArn=f"arn:{finding.partition}:securityhub:{finding.region}::product/prowler/prowler", ProductFields=ProductFields( ProwlerResourceName=finding.resource_uid, + ProwlerAccountOrganizationalUnitId=finding.account_ou_uid, + ProwlerAccountOrganizationalUnitName=finding.account_ou_name, ), GeneratorId="prowler-" + finding.metadata.CheckID, AwsAccountId=finding.account_uid, @@ -242,6 +244,8 @@ class ProductFields(BaseModel): ProviderName: str = "Prowler" ProviderVersion: str = prowler_version ProwlerResourceName: str + ProwlerAccountOrganizationalUnitId: Optional[str] = None + ProwlerAccountOrganizationalUnitName: Optional[str] = None class Severity(BaseModel): diff --git a/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py b/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py new file mode 100644 index 0000000000..a4b58bb3b4 --- /dev/null +++ b/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py @@ -0,0 +1,104 @@ +from prowler.config.config import timestamp +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 +from prowler.lib.outputs.finding import Finding + + +class GoogleWorkspaceCIS(ComplianceOutput): + """ + This class represents the Google Workspace CIS compliance output. + + Attributes: + - _data (list): A list to store transformed data from findings. + - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. + + Methods: + - transform: Transforms findings into Google Workspace CIS compliance format. + """ + + def transform( + self, + findings: list[Finding], + compliance: Compliance, + compliance_name: str, + ) -> None: + """ + Transforms a list of findings into Google Workspace CIS compliance format. + + Parameters: + - findings (list): A list of findings. + - compliance (Compliance): A compliance model. + - compliance_name (str): The name of the compliance model. + + Returns: + - None + """ + for finding in findings: + # Get the compliance requirements for the finding + finding_requirements = finding.compliance.get(compliance_name, []) + for requirement in compliance.Requirements: + if requirement.Id in finding_requirements: + for attribute in requirement.Attributes: + compliance_row = GoogleWorkspaceCISModel( + Provider=finding.provider, + Description=compliance.Description, + Domain=finding.account_name, + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_SubSection=attribute.SubSection, + Requirements_Attributes_Profile=attribute.Profile, + Requirements_Attributes_AssessmentStatus=attribute.AssessmentStatus, + Requirements_Attributes_Description=attribute.Description, + Requirements_Attributes_RationaleStatement=attribute.RationaleStatement, + Requirements_Attributes_ImpactStatement=attribute.ImpactStatement, + Requirements_Attributes_RemediationProcedure=attribute.RemediationProcedure, + Requirements_Attributes_AuditProcedure=attribute.AuditProcedure, + Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, + Requirements_Attributes_DefaultValue=attribute.DefaultValue, + Requirements_Attributes_References=attribute.References, + Status=finding.status, + StatusExtended=finding.status_extended, + ResourceId=finding.resource_uid, + ResourceName=finding.resource_name, + CheckId=finding.check_id, + Muted=finding.muted, + Framework=compliance.Framework, + Name=compliance.Name, + ) + self._data.append(compliance_row) + # Add manual requirements to the compliance output + for requirement in compliance.Requirements: + if not requirement.Checks: + for attribute in requirement.Attributes: + compliance_row = GoogleWorkspaceCISModel( + Provider=compliance.Provider.lower(), + Description=compliance.Description, + Domain="", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_SubSection=attribute.SubSection, + Requirements_Attributes_Profile=attribute.Profile, + Requirements_Attributes_AssessmentStatus=attribute.AssessmentStatus, + Requirements_Attributes_Description=attribute.Description, + Requirements_Attributes_RationaleStatement=attribute.RationaleStatement, + Requirements_Attributes_ImpactStatement=attribute.ImpactStatement, + Requirements_Attributes_RemediationProcedure=attribute.RemediationProcedure, + Requirements_Attributes_AuditProcedure=attribute.AuditProcedure, + Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, + Requirements_Attributes_DefaultValue=attribute.DefaultValue, + Requirements_Attributes_References=attribute.References, + Status="MANUAL", + StatusExtended="Manual check", + ResourceId="manual_check", + ResourceName="Manual check", + CheckId="manual", + Muted=False, + Framework=compliance.Framework, + Name=compliance.Name, + ) + self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/cis/models.py b/prowler/lib/outputs/compliance/cis/models.py index 9e96060078..0f4b320fd0 100644 --- a/prowler/lib/outputs/compliance/cis/models.py +++ b/prowler/lib/outputs/compliance/cis/models.py @@ -241,6 +241,39 @@ class OracleCloudCISModel(BaseModel): Name: str +class GoogleWorkspaceCISModel(BaseModel): + """ + GoogleWorkspaceCISModel generates a finding's output in Google Workspace CIS Compliance format. + """ + + Provider: str + Description: str + Domain: str + AssessmentDate: str + Requirements_Id: str + Requirements_Description: str + Requirements_Attributes_Section: str + Requirements_Attributes_SubSection: str + Requirements_Attributes_Profile: str + Requirements_Attributes_AssessmentStatus: str + Requirements_Attributes_Description: str + Requirements_Attributes_RationaleStatement: str + Requirements_Attributes_ImpactStatement: str + Requirements_Attributes_RemediationProcedure: str + Requirements_Attributes_AuditProcedure: str + Requirements_Attributes_AdditionalInformation: str + Requirements_Attributes_DefaultValue: str + Requirements_Attributes_References: str + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool + Framework: str + Name: str + + class AlibabaCloudCISModel(BaseModel): """ AlibabaCloudCISModel generates a finding's output in Alibaba Cloud CIS Compliance format. @@ -284,6 +317,7 @@ CIS_M365 = M365CISModel CIS_Github = GithubCISModel CIS_OracleCloud = OracleCloudCISModel CIS_AlibabaCloud = AlibabaCloudCISModel +CIS_GoogleWorkspace = GoogleWorkspaceCISModel # TODO: Create a parent class for the common fields of CIS and have the specific classes from each provider to inherit from it. diff --git a/prowler/lib/outputs/compliance/cisa_scuba/__init__.py b/prowler/lib/outputs/compliance/cisa_scuba/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py b/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py new file mode 100644 index 0000000000..8250d1bcce --- /dev/null +++ b/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py @@ -0,0 +1,90 @@ +from prowler.config.config import timestamp +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.cisa_scuba.models import ( + GoogleWorkspaceCISASCuBAModel, +) +from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput +from prowler.lib.outputs.finding import Finding + + +class GoogleWorkspaceCISASCuBA(ComplianceOutput): + """ + This class represents the Google Workspace CISA SCuBA compliance output. + + Attributes: + - _data (list): A list to store transformed data from findings. + - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. + + Methods: + - transform: Transforms findings into Google Workspace CISA SCuBA compliance format. + """ + + def transform( + self, + findings: list[Finding], + compliance: Compliance, + compliance_name: str, + ) -> None: + """ + Transforms a list of findings into Google Workspace CISA SCuBA compliance format. + + Parameters: + - findings (list): A list of findings. + - compliance (Compliance): A compliance model. + - compliance_name (str): The name of the compliance model. + + Returns: + - None + """ + for finding in findings: + # Get the compliance requirements for the finding + finding_requirements = finding.compliance.get(compliance_name, []) + for requirement in compliance.Requirements: + if requirement.Id in finding_requirements: + for attribute in requirement.Attributes: + compliance_row = GoogleWorkspaceCISASCuBAModel( + Provider=finding.provider, + Description=compliance.Description, + Domain=finding.account_name, + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_SubSection=attribute.SubSection, + Requirements_Attributes_Service=attribute.Service, + Requirements_Attributes_Type=attribute.Type, + Status=finding.status, + StatusExtended=finding.status_extended, + ResourceId=finding.resource_uid, + ResourceName=finding.resource_name, + CheckId=finding.check_id, + Muted=finding.muted, + Framework=compliance.Framework, + Name=compliance.Name, + ) + self._data.append(compliance_row) + # Add manual requirements to the compliance output + for requirement in compliance.Requirements: + if not requirement.Checks: + for attribute in requirement.Attributes: + compliance_row = GoogleWorkspaceCISASCuBAModel( + Provider=compliance.Provider.lower(), + Description=compliance.Description, + Domain="", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_SubSection=attribute.SubSection, + Requirements_Attributes_Service=attribute.Service, + Requirements_Attributes_Type=attribute.Type, + Status="MANUAL", + StatusExtended="Manual check", + ResourceId="manual_check", + ResourceName="Manual check", + CheckId="manual", + Muted=False, + Framework=compliance.Framework, + Name=compliance.Name, + ) + self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/cisa_scuba/models.py b/prowler/lib/outputs/compliance/cisa_scuba/models.py new file mode 100644 index 0000000000..088da6a383 --- /dev/null +++ b/prowler/lib/outputs/compliance/cisa_scuba/models.py @@ -0,0 +1,28 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + + +class GoogleWorkspaceCISASCuBAModel(BaseModel): + """ + GoogleWorkspaceCISASCuBAModel generates a finding's output in Google Workspace CISA SCuBA Compliance format. + """ + + Provider: str + Description: str + Domain: str + AssessmentDate: str + Requirements_Id: str + Requirements_Description: str + Requirements_Attributes_Section: Optional[str] = None + Requirements_Attributes_SubSection: Optional[str] = None + Requirements_Attributes_Service: Optional[str] = None + Requirements_Attributes_Type: Optional[str] = None + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool + Framework: str + Name: str diff --git a/prowler/lib/outputs/csv/csv.py b/prowler/lib/outputs/csv/csv.py index 9f850450fb..bcb50d9433 100644 --- a/prowler/lib/outputs/csv/csv.py +++ b/prowler/lib/outputs/csv/csv.py @@ -82,6 +82,8 @@ class CSV(Output): finding_dict["ADDITIONAL_URLS"] = unroll_list( finding.metadata.AdditionalURLs ) + finding_dict["ACCOUNT_OU_UID"] = finding.account_ou_uid + finding_dict["ACCOUNT_OU_NAME"] = finding.account_ou_name self._data.append(finding_dict) except Exception as error: logger.error( diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 304a1857bf..26fbe327ee 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -34,10 +34,13 @@ class Finding(BaseModel): auth_method: str timestamp: Union[int, datetime] account_uid: str + provider_uid: Optional[str] = None account_name: Optional[str] = None account_email: Optional[str] = None account_organization_uid: Optional[str] = None account_organization_name: Optional[str] = None + account_ou_uid: Optional[str] = None + account_ou_name: Optional[str] = None metadata: CheckMetadata account_tags: dict = Field(default_factory=dict) uid: str @@ -154,6 +157,12 @@ class Finding(BaseModel): output_data["account_tags"] = get_nested_attribute( provider, "organizations_metadata.account_tags" ) + output_data["account_ou_uid"] = get_nested_attribute( + provider, "organizations_metadata.account_ou_id" + ) + output_data["account_ou_name"] = get_nested_attribute( + provider, "organizations_metadata.account_ou_name" + ) output_data["partition"] = get_nested_attribute( provider, "identity.partition" ) @@ -244,6 +253,7 @@ class Finding(BaseModel): output_data["account_uid"] = get_nested_attribute( provider, "identity.cluster" ) + output_data["provider_uid"] = provider.identity.context output_data["region"] = f"namespace: {check_output.namespace}" elif provider.type == "github": @@ -369,12 +379,48 @@ class Finding(BaseModel): elif provider.type == "cloudflare": output_data["auth_method"] = "api_token" - output_data["account_uid"] = check_output.account_id - output_data["account_name"] = check_output.account_id + account_id = check_output.account_id + if not account_id: + audited_accounts = ( + get_nested_attribute(provider, "identity.audited_accounts") + or [] + ) + if audited_accounts: + account_id = audited_accounts[0] + + account_name = account_id + if account_id: + accounts = get_nested_attribute(provider, "identity.accounts") or [] + for account in accounts: + if getattr(account, "id", None) == account_id and getattr( + account, "name", None + ): + account_name = account.name + break + + output_data["account_uid"] = account_id or "" + output_data["account_name"] = account_name or account_id or "" output_data["resource_name"] = check_output.resource_name output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.zone_name + elif provider.type == "vercel": + output_data["auth_method"] = "api_token" + team = get_nested_attribute(provider, "identity.team") + output_data["account_uid"] = ( + team.id + if team + else get_nested_attribute(provider, "identity.user_id") + ) + output_data["account_name"] = ( + team.name + if team + else get_nested_attribute(provider, "identity.username") + ) + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = "global" + elif provider.type == "alibabacloud": output_data["auth_method"] = get_nested_attribute( provider, "identity.identity_arn" @@ -432,6 +478,9 @@ class Finding(BaseModel): f"{output_data['region']}-{output_data['resource_name']}" ) + if provider.type == "iac" and output_data.get("resource_line_range"): + output_data["uid"] += f"-{output_data['resource_line_range']}" + if not output_data["resource_uid"]: logger.error( f"Check {check_output.check_metadata.CheckID} has no resource_uid." diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index d17320453b..d547ea3b95 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -9,6 +9,7 @@ from prowler.config.config import ( square_logo_img, timestamp, ) +from prowler.lib.cli.redact import redact_argv from prowler.lib.logger import logger from prowler.lib.outputs.output import Finding, Output from prowler.lib.outputs.utils import parse_html_string, unroll_dict @@ -196,7 +197,7 @@ class HTML(Output):

  • - Parameters used: {" ".join(sys.argv[1:]) if from_cli else ""} + Parameters used: {redact_argv(sys.argv[1:]) if from_cli else ""}
  • Date: {timestamp.isoformat()} @@ -1331,6 +1332,71 @@ class HTML(Output): ) return "" + @staticmethod + def get_vercel_assessment_summary(provider: Provider) -> str: + """ + get_vercel_assessment_summary gets the HTML assessment summary for the Vercel provider + + Args: + provider (Provider): the Vercel provider object + + Returns: + str: HTML assessment summary for the Vercel provider + """ + try: + assessment_items = "" + + team = getattr(provider.identity, "team", None) + if team: + assessment_items += f""" +
  • + Team: {team.name} ({team.id}) +
  • """ + + credentials_items = """ +
  • + Authentication: API Token +
  • """ + + email = getattr(provider.identity, "email", None) + if email: + credentials_items += f""" +
  • + Email: {email} +
  • """ + + username = getattr(provider.identity, "username", None) + if username: + credentials_items += f""" +
  • + Username: {username} +
  • """ + + return f""" +
    +
    +
    + Vercel Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Vercel Credentials +
    +
      {credentials_items} +
    +
    +
    """ + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_assessment_summary(provider: Provider) -> str: """ diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index 5b21b89153..ed8f7faab0 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -44,9 +44,11 @@ class JiraConnection(Connection): Represents a Jira connection object. Attributes: projects (dict): Dictionary of projects in Jira. + issue_types (dict): Dictionary of issue types per project key. """ projects: dict = None + issue_types: dict = None class MarkdownToADFConverter: @@ -781,7 +783,20 @@ class Jira: ) projects = jira.get_projects() - return JiraConnection(is_connected=True, projects=projects) + issue_types = {} + for project_key in projects: + try: + issue_types[project_key] = jira.get_available_issue_types( + project_key + ) + except Exception as e: + logger.warning( + f"Failed to get issue types for project {project_key}: {e}" + ) + + return JiraConnection( + is_connected=True, projects=projects, issue_types=issue_types + ) except JiraNoProjectsError as no_projects_error: logger.error( f"{no_projects_error.__class__.__name__}[{no_projects_error.__traceback__.tb_lineno}]: {no_projects_error}" diff --git a/prowler/lib/outputs/ocsf/ingestion.py b/prowler/lib/outputs/ocsf/ingestion.py index 5fc58225e4..c3314a0ebe 100644 --- a/prowler/lib/outputs/ocsf/ingestion.py +++ b/prowler/lib/outputs/ocsf/ingestion.py @@ -21,9 +21,9 @@ def send_ocsf_to_api( Args: file_path: Path to the OCSF JSON file to upload. - base_url: API base URL. Falls back to PROWLER_CLOUD_API_BASE env var, + base_url: API base URL. Falls back to PROWLER_CLOUD_API_BASE_URL env var, then to https://api.prowler.com. - api_key: API key. Falls back to PROWLER_API_KEY env var. + api_key: API key. Falls back to PROWLER_CLOUD_API_KEY env var. timeout: Request timeout in seconds. Returns: @@ -42,7 +42,9 @@ def send_ocsf_to_api( api_key = api_key or cloud_api_key if not api_key: - raise ValueError("Missing API key. Set PROWLER_API_KEY environment variable.") + raise ValueError( + "Missing API key. Set PROWLER_CLOUD_API_KEY environment variable." + ) base_url = base_url or cloud_api_base_url base_url = base_url.rstrip("/") diff --git a/prowler/lib/outputs/ocsf/ocsf.py b/prowler/lib/outputs/ocsf/ocsf.py index 48b44c6b00..731d029284 100644 --- a/prowler/lib/outputs/ocsf/ocsf.py +++ b/prowler/lib/outputs/ocsf/ocsf.py @@ -178,6 +178,8 @@ class OCSF(Output): "notes": finding.metadata.Notes, "compliance": finding.compliance, "scan_id": str(scan_id), + "provider_uid": finding.provider_uid or finding.account_uid, + "provider": finding.provider, }, ) if finding.provider != "kubernetes": @@ -192,7 +194,8 @@ class OCSF(Output): org=Organization( uid=finding.account_organization_uid, name=finding.account_organization_name, - # TODO: add the org unit id and name + ou_uid=finding.account_ou_uid, + ou_name=finding.account_ou_name, ), provider=finding.provider, region=finding.region, diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index 82d3494a5e..1e2f6f2058 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -38,6 +38,8 @@ def stdout_report(finding, color, verbose, status, fix): details = finding.zone_name if finding.check_metadata.Provider == "googleworkspace": details = finding.location + if finding.check_metadata.Provider == "vercel": + details = finding.region if (verbose or fix) and (not status or finding.status in status): if finding.muted: diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 5f50f53c81..635139117d 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -99,6 +99,14 @@ def display_summary_table( elif provider.type == "image": entity_type = "Image" audited_entities = ", ".join(provider.images) + elif provider.type == "vercel": + entity_type = "Team" + if provider.identity.team: + audited_entities = ( + f"{provider.identity.team.name} ({provider.identity.team.slug})" + ) + else: + audited_entities = provider.identity.username or "Personal Account" # Check if there are findings and that they are not all MANUAL if findings and not all(finding.status == "MANUAL" for finding in findings): diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py index 0b38f16b1b..2ce7263e2b 100644 --- a/prowler/lib/scan/scan.py +++ b/prowler/lib/scan/scan.py @@ -302,7 +302,12 @@ class Scan: for report in iac_reports: # Generate unique UID for the finding - finding_uid = f"{report.check_metadata.CheckID}-{report.resource_name}-{report.resource_line_range}" + finding_uid = ( + f"prowler-iac-{report.check_metadata.CheckID}-iac-" + f"{report.region}-{report.resource_name}" + ) + if report.resource_line_range: + finding_uid += f"-{report.resource_line_range}" # Convert status string to Status enum status_enum = ( diff --git a/prowler/providers/alibabacloud/services/actiontrail/actiontrail_multi_region_enabled/actiontrail_multi_region_enabled.metadata.json b/prowler/providers/alibabacloud/services/actiontrail/actiontrail_multi_region_enabled/actiontrail_multi_region_enabled.metadata.json index cb49461ce0..bb760f450e 100644 --- a/prowler/providers/alibabacloud/services/actiontrail/actiontrail_multi_region_enabled/actiontrail_multi_region_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/actiontrail/actiontrail_multi_region_enabled/actiontrail_multi_region_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "actiontrail_multi_region_enabled", - "CheckTitle": "ActionTrail are configured to export copies of all Log entries", - "CheckType": [ - "Unusual logon", - "Cloud threat detection" - ], + "CheckTitle": "ActionTrail is configured to export copies of all log entries across all regions", + "CheckType": [], "ServiceName": "actiontrail", "SubServiceName": "", - "ResourceIdTemplate": "acs:actiontrail::account-id:trail", + "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "AlibabaCloudActionTrail", + "ResourceType": "ALIYUN::ACTIONTRAIL::Trail", "ResourceGroup": "monitoring", - "Description": "**ActionTrail** is a web service that records API calls for your account and delivers log files to you.\n\nThe 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 Alibaba Cloud service. ActionTrail provides a history of API calls for an account, including API calls made via the Management Console, SDKs, and command line tools.", - "Risk": "The API call history produced by ActionTrail enables **security analysis**, **resource change tracking**, and **compliance auditing**.\n\nEnsuring that a **multi-region trail** exists will detect unexpected activities occurring in otherwise unused regions. Global Service Logging should be enabled by default to capture events generated on Alibaba Cloud global services, ensuring the recording of management operations performed on all resources in an Alibaba Cloud account.", + "Description": "**Alibaba Cloud ActionTrail** records API calls made to your account, including caller identity, time, source IP, request parameters, and response elements. Ensuring a **multi-region trail** exists guarantees that operations across all regions and global services are captured, enabling detection of unexpected activities in unused regions.", + "Risk": "Without a **multi-region trail** enabled, API calls made in regions outside the primary trail's scope will not be recorded. This creates blind spots in **security analysis**, **resource change tracking**, and **compliance auditing**, potentially allowing unauthorized or malicious activity to go undetected across your Alibaba Cloud account.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/28829.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ActionTrail/enable-multi-region-trails.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ActionTrail/enable-multi-region-trails.html" ], "Remediation": { "Code": { "CLI": "aliyun actiontrail CreateTrail --Name --OssBucketName --RoleName aliyunactiontraildefaultrole --SlsProjectArn --SlsWriteRoleArn --EventRW ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ActionTrail Console**\n2. Click on **Trails** in the left navigation pane\n3. Click **Add new trail**\n4. Enter a trail name in the `Trail name` box\n5. Set **Yes** for `Apply Trail to All Regions`\n6. Specify an OSS bucket name in the `OSS bucket` box\n7. Specify an SLS project name in the `SLS project` box\n8. Click **Create**", "Terraform": "resource \"alicloud_actiontrail_trail\" \"example\" {\n trail_name = \"multi-region-trail\"\n trail_region = \"All\"\n sls_project_arn = \"acs:log:cn-hangzhou:123456789:project/actiontrail-project\"\n sls_write_role_arn = data.alicloud_ram_roles.actiontrail.roles.0.arn\n}" }, "Recommendation": { - "Text": "1. Log on to the **ActionTrail Console**\n2. Click on **Trails** in the left navigation pane\n3. Click **Add new trail**\n4. Enter a trail name in the `Trail name` box\n5. Set **Yes** for `Apply Trail to All Regions`\n6. Specify an OSS bucket name in the `OSS bucket` box\n7. Specify an SLS project name in the `SLS project` box\n8. Click **Create**", + "Text": "Enable a multi-region trail in ActionTrail to ensure all API calls across all regions are recorded and delivered to a centralized OSS bucket and SLS project for security analysis and compliance auditing.", "Url": "https://hub.prowler.com/check/actiontrail_multi_region_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/actiontrail/actiontrail_oss_bucket_not_publicly_accessible/actiontrail_oss_bucket_not_publicly_accessible.metadata.json b/prowler/providers/alibabacloud/services/actiontrail/actiontrail_oss_bucket_not_publicly_accessible/actiontrail_oss_bucket_not_publicly_accessible.metadata.json index 7505a7404a..55daf22591 100644 --- a/prowler/providers/alibabacloud/services/actiontrail/actiontrail_oss_bucket_not_publicly_accessible/actiontrail_oss_bucket_not_publicly_accessible.metadata.json +++ b/prowler/providers/alibabacloud/services/actiontrail/actiontrail_oss_bucket_not_publicly_accessible/actiontrail_oss_bucket_not_publicly_accessible.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "actiontrail_oss_bucket_not_publicly_accessible", - "CheckTitle": "The OSS used to store ActionTrail logs is not publicly accessible", - "CheckType": [ - "Sensitive file tampering" - ], + "CheckTitle": "The OSS bucket used to store ActionTrail logs is not publicly accessible", + "CheckType": [], "ServiceName": "actiontrail", "SubServiceName": "", - "ResourceIdTemplate": "acs:oss::account-id:bucket-name", + "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "AlibabaCloudOSSBucket", + "ResourceType": "ALIYUN::ACTIONTRAIL::Trail", "ResourceGroup": "storage", - "Description": "**ActionTrail** logs a record of every API call made in your Alibaba Cloud account. These log files are stored in an **OSS bucket**.\n\nIt is recommended that the **Access Control List (ACL)** of the OSS bucket, which ActionTrail logs to, prevents public access to the ActionTrail logs.", - "Risk": "Allowing **public access** to ActionTrail log content may aid an adversary in identifying weaknesses in the affected account's use or configuration.\n\nExposed audit logs can reveal sensitive information about your infrastructure, API usage patterns, and security configurations.", + "Description": "**Alibaba Cloud ActionTrail** logs a record of every API call made in your account and stores these log files in an **OSS bucket**. It is recommended that the **Access Control List (ACL)** of the OSS bucket used by ActionTrail is set to `private` to prevent unauthorized public access to sensitive audit log data.", + "Risk": "Allowing **public access** to the OSS bucket containing ActionTrail logs may expose sensitive information about your infrastructure, API usage patterns, and security configurations. An adversary could use this information to identify weaknesses in the affected account, leading to potential **data breaches**, **privilege escalation**, and **compliance violations**.", "RelatedUrl": "", "AdditionalURLs": [ - "https://help.aliyun.com/document_detail/31954.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ActionTrail/trail-bucket-publicly-accessible.html" + "https://www.alibabacloud.com/help/doc-detail/31954.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ActionTrail/trail-bucket-publicly-accessible.html" ], "Remediation": { "Code": { "CLI": "ossutil set-acl oss:// private -b", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **OSS Console**\n2. Right-click on the bucket and select **Basic Settings**\n3. In the Access Control List pane, click **Configure**\n4. The Bucket ACL tab shows three types of grants: `Private`, `Public Read`, `Public Read/Write`\n5. Ensure **Private** is set for the bucket\n6. Click **Save** to save the ACL", "Terraform": "resource \"alicloud_oss_bucket_public_access_block\" \"actiontrail\" {\n bucket = alicloud_oss_bucket.actiontrail.bucket\n block_public_access = true\n}" }, "Recommendation": { - "Text": "1. Log on to the **OSS Console**\n2. Right-click on the bucket and select **Basic Settings**\n3. In the Access Control List pane, click **Configure**\n4. The Bucket ACL tab shows three types of grants: `Private`, `Public Read`, `Public Read/Write`\n5. Ensure **Private** is set for the bucket\n6. Click **Save** to save the ACL", + "Text": "Set the ACL of the OSS bucket used to store ActionTrail logs to private to prevent unauthorized public access to sensitive audit log data.", "Url": "https://hub.prowler.com/check/actiontrail_oss_bucket_not_publicly_accessible" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cloudmonitor_enabled/cs_kubernetes_cloudmonitor_enabled.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cloudmonitor_enabled/cs_kubernetes_cloudmonitor_enabled.metadata.json index 7685aa4f2e..79317727bf 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cloudmonitor_enabled/cs_kubernetes_cloudmonitor_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cloudmonitor_enabled/cs_kubernetes_cloudmonitor_enabled.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_cloudmonitor_enabled", - "CheckTitle": "CloudMonitor is set to Enabled on Kubernetes Engine Clusters", - "CheckType": [ - "Threat detection during container runtime" - ], + "CheckTitle": "Kubernetes cluster has CloudMonitor enabled", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "The monitoring service in **Kubernetes Engine clusters** depends on the Alibaba Cloud **CloudMonitor** agent to access additional system resources and application services in virtual machine instances.\n\nThe monitor can access metrics about CPU utilization, disk traffic metrics, network traffic, and disk IO information, which help monitor signals and build operations in your Kubernetes Engine clusters.", - "Risk": "Without **CloudMonitor** enabled, you lack visibility into system metrics and custom metrics. System metrics measure the cluster's infrastructure, such as CPU or memory usage.\n\nWith CloudMonitor, a monitor controller is created that periodically connects to each node and collects metrics about its Pods and containers, then sends the metrics to CloudMonitor server.", + "Description": "**CloudMonitor** agent provides visibility into system metrics for **Kubernetes Engine clusters**, including CPU, disk, network, and IO. Without it, operators lack observability into node and pod health. Enabling CloudMonitor creates a controller that collects metrics from each node's Pods and containers for analysis and alerting.", + "Risk": "Without **CloudMonitor**, there is no automated collection of system metrics (CPU, memory, disk, network). This delays detection of **resource exhaustion**, **node failures**, and **abnormal workloads**, increasing risk of undetected **availability** issues. It also impairs identification of **denial-of-service** or **cryptojacking** on cluster nodes.", "RelatedUrl": "", "AdditionalURLs": [ - "https://help.aliyun.com/document_detail/125508.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-cloud-monitor.html" + "https://www.alibabacloud.com/help/en/ack/", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-cloud-monitor.html" ], "Remediation": { "Code": { - "CLI": "aliyun cs GET /clusters/[cluster_id]/nodepools to verify nodepools.kubernetes_config.cms_enabled is set to true for all node pools.", + "CLI": "aliyun cs GET /clusters//nodepools --header 'Content-Type=application/json' | jq '.nodepools[].kubernetes_config.cms_enabled'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ACK Console**.\n2. Select the target cluster and click its name to open the cluster detail page.\n3. Select **Nodes** on the left column and click the **Monitor** link on the Actions column of the selected node.\n4. Verify that OS Metrics data exists in the CloudMonitor page.\n5. To enable: Click **Create Kubernetes Cluster** and set `CloudMonitor Agent` to **Enabled** under creation options.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **ACK Console**\n2. Select the target cluster and click its name to open the cluster detail page\n3. Select **Nodes** on the left column and click the **Monitor** link on the Actions column of the selected node\n4. Verify that OS Metrics data exists in the CloudMonitor page\n5. To enable: Click **Create Kubernetes Cluster** and set `CloudMonitor Agent` to **Enabled** under creation options", + "Text": "Enable the **CloudMonitor** agent during cluster creation by setting `CloudMonitor Agent` to **Enabled**. For existing clusters, verify that `cms_enabled` is set to `true` for all node pools.", "Url": "https://hub.prowler.com/check/cs_kubernetes_cloudmonitor_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_recent/cs_kubernetes_cluster_check_recent.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_recent/cs_kubernetes_cluster_check_recent.metadata.json index ed87a8c2fb..d805ca1629 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_recent/cs_kubernetes_cluster_check_recent.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_recent/cs_kubernetes_cluster_check_recent.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_cluster_check_recent", - "CheckTitle": "Cluster Check triggered within configured period for Kubernetes Clusters", - "CheckType": [ - "Threat detection during container runtime" - ], + "CheckTitle": "Kubernetes cluster health check has been triggered within the configured period", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "**Kubernetes Engine's cluster check** feature helps you verify the system nodes and components healthy status.\n\nWhen you trigger the checking, the process validates the health state of each node in your cluster and also the cluster configuration (`kubelet`, `docker daemon`, `kernel`, and network `iptables` configuration). If there are consecutive health check failures, the diagnose reports to admin for further repair.", - "Risk": "Kubernetes Engine uses the node's health status to determine if a node needs to be repaired. A cluster health check includes: cloud resource healthy status including **VPC/VSwitch**, **SLB**, and every **ECS node** status in the cluster; the `kubelet`, `docker daemon`, `kernel`, `iptables` configurations on every node.\n\nWithout regular cluster checks, potential issues may go undetected and could lead to **cluster instability** or **security vulnerabilities**.", + "Description": "**Alibaba Cloud Kubernetes Engine** provides a cluster health check that validates node health and cluster configuration, including `kubelet`, `docker daemon`, `kernel`, and `iptables` settings. Running checks regularly ensures **VPC/VSwitch**, **SLB**, and **ECS nodes** function correctly. Consecutive failures generate diagnostic reports for corrective action.", + "Risk": "Without regular cluster health checks, **node failures**, **misconfigured network rules**, or **degraded components** may go undetected, increasing the risk of **cluster instability**, **service outages**, and exploitable **security vulnerabilities**. Delayed detection of unhealthy nodes can impact the **integrity** and **availability** of workloads running on the cluster.", "RelatedUrl": "", "AdditionalURLs": [ - "https://help.aliyun.com/document_detail/114882.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/cluster-check.html" + "https://www.alibabacloud.com/help/en/ack/", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/cluster-check.html" ], "Remediation": { "Code": { - "CLI": "aliyun cs GET /clusters/[cluster_id]/checks to verify cluster checks are being run regularly. Trigger a check if needed.", + "CLI": "aliyun cs GET /clusters//checks --header 'Content-Type=application/json'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ACK Console**.\n2. Select the target cluster and open the **More** pop-menu for advanced options.\n3. Select **Global Check** and click the **Start** button to trigger the checking.\n4. Verify the checking time and details in Global Check.\n5. It is recommended to trigger cluster checks at least once within the configured period.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **ACK Console**\n2. Select the target cluster and open the **More** pop-menu for advanced options\n3. Select **Global Check** and click the **Start** button to trigger the checking\n4. Verify the checking time and details in Global Check\n5. It is recommended to trigger cluster checks at least once within the configured period (default: weekly)", + "Text": "Trigger a cluster health check regularly within the configured period to ensure all nodes and system components are healthy. Use the **Global Check** feature in the ACK Console or the `aliyun cs` CLI to verify and trigger checks.", "Url": "https://hub.prowler.com/check/cs_kubernetes_cluster_check_recent" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_weekly/cs_kubernetes_cluster_check_weekly.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_weekly/cs_kubernetes_cluster_check_weekly.metadata.json index db61e88fe2..a18b4a34c6 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_weekly/cs_kubernetes_cluster_check_weekly.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_cluster_check_weekly/cs_kubernetes_cluster_check_weekly.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_cluster_check_weekly", - "CheckTitle": "Cluster Check triggered at least once per week for Kubernetes Clusters", - "CheckType": [ - "Threat detection during container runtime" - ], + "CheckTitle": "Kubernetes cluster health check has been triggered at least once per week", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "**Kubernetes Engine's cluster check** feature helps you verify the system nodes and components healthy status.\n\nWhen you trigger the checking, the process validates the health state of each node in your cluster and also the cluster configuration (`kubelet`, `docker daemon`, `kernel`, and network `iptables` configuration). If there are consecutive health check failures, the diagnose reports to admin for further repair.", - "Risk": "Kubernetes Engine uses the node's health status to determine if a node needs to be repaired. A cluster health check includes: cloud resource healthy status including **VPC/VSwitch**, **SLB**, and every **ECS node** status in the cluster; the `kubelet`, `docker daemon`, `kernel`, `iptables` configurations on every node.\n\nWithout regular cluster checks, potential issues may go undetected and could lead to **cluster instability** or **security vulnerabilities**.", + "Description": "**Alibaba Cloud Kubernetes Engine** provides a cluster health check that validates node health and cluster configuration, including `kubelet`, `docker daemon`, `kernel`, and `iptables` settings. Weekly checks ensure **VPC/VSwitch**, **SLB**, and **ECS nodes** function correctly. Consecutive failures generate diagnostic reports for corrective action.", + "Risk": "Without weekly health checks, **node failures**, **misconfigured network rules**, or **degraded components** may go undetected for extended periods, increasing the risk of **cluster instability**, **service outages**, and exploitable **security vulnerabilities**. Delayed detection can impact the **integrity** and **availability** of workloads on the cluster.", "RelatedUrl": "", "AdditionalURLs": [ - "https://help.aliyun.com/document_detail/114882.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/cluster-check.html" + "https://www.alibabacloud.com/help/en/ack/", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/cluster-check.html" ], "Remediation": { "Code": { - "CLI": "aliyun cs GET /clusters/[cluster_id]/checks to verify cluster checks are being run regularly. Trigger a check if needed.", + "CLI": "aliyun cs GET /clusters//checks --header 'Content-Type=application/json'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ACK Console**.\n2. Select the target cluster and open the **More** pop-menu for advanced options.\n3. Select **Global Check** and click the **Start** button to trigger the checking.\n4. Verify the checking time and details in Global Check.\n5. Trigger cluster checks at least once per week.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **ACK Console**\n2. Select the target cluster and open the **More** pop-menu for advanced options\n3. Select **Global Check** and click the **Start** button to trigger the checking\n4. Verify the checking time and details in Global Check\n5. It is recommended to trigger cluster checks at least once per week", + "Text": "Trigger a cluster health check at least once per week to ensure all nodes and system components are healthy. Use the **Global Check** feature in the ACK Console or the `aliyun cs` CLI to verify and trigger checks.", "Url": "https://hub.prowler.com/check/cs_kubernetes_cluster_check_weekly" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_dashboard_disabled/cs_kubernetes_dashboard_disabled.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_dashboard_disabled/cs_kubernetes_dashboard_disabled.metadata.json index 4d0b3ed936..e933e258d2 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_dashboard_disabled/cs_kubernetes_dashboard_disabled.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_dashboard_disabled/cs_kubernetes_dashboard_disabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_dashboard_disabled", - "CheckTitle": "Kubernetes web UI / Dashboard is not enabled", - "CheckType": [ - "Threat detection during container runtime", - "Unusual logon" - ], + "CheckTitle": "Kubernetes web UI (Dashboard) is disabled on Kubernetes Engine clusters", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "**Dashboard** is a web-based Kubernetes user interface that can be used to deploy containerized applications to a Kubernetes cluster, troubleshoot your containerized application, and manage the cluster itself.\n\nYou should disable the **Kubernetes Web UI (Dashboard)** when running on Kubernetes Engine. The Dashboard is backed by a highly privileged Kubernetes Service Account. It is recommended to use the **ACK User Console** instead to avoid privilege escalation via a compromised dashboard.", - "Risk": "The **Kubernetes Dashboard** is backed by a highly privileged Service Account. If the Dashboard is compromised, it could allow an attacker to gain **full control** over the cluster and potentially **escalate privileges**.\n\nAttackers who gain access to the Dashboard can deploy malicious workloads, exfiltrate secrets, and compromise the entire cluster.", + "Description": "**Alibaba Cloud Kubernetes Engine** clusters should not have the **Kubernetes Dashboard** (web UI) enabled. The Dashboard uses a highly privileged Service Account that can perform administrative operations across the cluster. Use the **ACK Console** instead, which provides fine-grained access control through RAM policies and RBAC integration.", + "Risk": "The **Kubernetes Dashboard** uses a highly privileged Service Account with broad cluster access. If compromised through a vulnerability or unauthorized access, an attacker could gain **full control** over the cluster, deploy malicious workloads, exfiltrate **secrets**, and **escalate privileges**, impacting **confidentiality**, **integrity**, and **availability** of all workloads and data.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/86494.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/disable-kubernetes-dashboard.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/disable-kubernetes-dashboard.html" ], "Remediation": { "Code": { - "CLI": "Use kubectl to delete the dashboard deployment: kubectl delete deployment kubernetes-dashboard -n kube-system", + "CLI": "kubectl delete deployment kubernetes-dashboard -n kube-system", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ACK Console**.\n2. Select the target cluster and select the `kube-system` namespace in the Namespace pop-menu.\n3. Input `dashboard` in the deploy filter bar.\n4. Make sure there is no result after the filter.\n5. If dashboard exists, delete the deployment by selecting **Delete** in the More pop-menu.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **ACK Console**\n2. Select the target cluster and select the `kube-system` namespace in the Namespace pop-menu\n3. Input `dashboard` in the deploy filter bar\n4. Make sure there is no result after the filter\n5. If dashboard exists, delete the deployment by selecting **Delete** in the More pop-menu", + "Text": "Delete the Kubernetes Dashboard deployment from the `kube-system` namespace using `kubectl` or the ACK Console. Use the **ACK Console** for cluster management instead of the Kubernetes Dashboard.", "Url": "https://hub.prowler.com/check/cs_kubernetes_dashboard_disabled" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_eni_multiple_ip_enabled/cs_kubernetes_eni_multiple_ip_enabled.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_eni_multiple_ip_enabled/cs_kubernetes_eni_multiple_ip_enabled.metadata.json index 29392fa152..4bb208b4a4 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_eni_multiple_ip_enabled/cs_kubernetes_eni_multiple_ip_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_eni_multiple_ip_enabled/cs_kubernetes_eni_multiple_ip_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_eni_multiple_ip_enabled", - "CheckTitle": "ENI multiple IP mode support for Kubernetes Cluster", - "CheckType": [ - "Threat detection during container runtime", - "Suspicious network connection" - ], + "CheckTitle": "Kubernetes cluster has ENI multiple IP mode enabled", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "Alibaba Cloud **ENI (Elastic Network Interface)** supports assigning ranges of internal IP addresses as aliases to a single virtual machine's ENI network interfaces.\n\nWith **ENI multiple IP mode**, Kubernetes Engine clusters can allocate IP addresses from a CIDR block known to **Terway** network plugin. This makes your cluster more scalable and allows better interaction with other Alibaba Cloud products.", - "Risk": "Without **ENI multiple IP mode** (provided by Terway), pods share the node's network interface in a less scalable way.\n\nUsing ENI multiple IPs allows pod IPs to be reserved within the network ahead of time, preventing conflict with other compute resources, and allows firewall controls for Pods to be applied separately from their nodes.", + "Description": "With **ENI multiple IP mode** provided by the **Terway** network plugin, Kubernetes Engine clusters allocate pod IPs from the VPC CIDR block, enabling better scalability and native integration with Alibaba Cloud services. This mode allows pods to have their own security group associations, providing granular network-level access control independently from host nodes.", + "Risk": "Without **ENI multiple IP mode** (**Terway** plugin), pods share the node's network interface and cannot have independent security group associations. This limits **granular firewall controls** at the pod level, increasing **lateral movement** risk if a pod is compromised. The inability to isolate pod from node networking weakens **network segmentation** and the cluster's **security posture**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/ack/ack-managed-and-ack-dedicated/user-guide/associate-multiple-security-groups-with-an-eni", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-multi-ip-mode.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-multi-ip-mode.html" ], "Remediation": { "Code": { - "CLI": "Terway network plugin must be selected during cluster creation to support ENI multiple IP mode.", + "CLI": "aliyun cs GET /clusters/ --header 'Content-Type=application/json' | jq '.parameters.Network'", "NativeIaC": "", - "Other": "", + "Other": "1. When creating a new cluster in the **ACK Console**, select **Terway** in the `Network Plugin` option to enable ENI multiple IP mode support.\n2. Note that existing clusters using **Flannel** cannot be migrated to **Terway**.", "Terraform": "" }, "Recommendation": { - "Text": "When creating a new cluster, select **Terway** in the `Network Plugin` option to enable ENI multiple IP mode support.\n\n**Note:** Existing clusters using Flannel cannot be migrated to Terway.", + "Text": "Select the **Terway** network plugin during cluster creation to enable ENI multiple IP mode. Existing clusters using **Flannel** cannot be migrated to Terway and must be recreated.", "Url": "https://hub.prowler.com/check/cs_kubernetes_eni_multiple_ip_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_log_service_enabled/cs_kubernetes_log_service_enabled.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_log_service_enabled/cs_kubernetes_log_service_enabled.metadata.json index 22405dd674..78e2433f0c 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_log_service_enabled/cs_kubernetes_log_service_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_log_service_enabled/cs_kubernetes_log_service_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_log_service_enabled", - "CheckTitle": "Log Service is set to Enabled on Kubernetes Engine Clusters", - "CheckType": [ - "Threat detection during container runtime" - ], + "CheckTitle": "Kubernetes cluster has Log Service enabled", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "**Log Service** is a complete real-time data logging service on Alibaba Cloud supporting collection, shipping, search, storage, and analysis for logs.\n\nLog Service can automatically collect, process, and store your container and audit logs in a dedicated, persistent datastore. Container logs are collected from your containers, audit logs from the `kube-apiserver` or deployed ingress, and events about cluster activity such as the deletion of Pods or Secrets.", - "Risk": "Without **Log Service** enabled, you lose visibility into container and system logs. The per-node logging agent collects: `kube-apiserver` audit logs, ingress visiting logs, and standard output/error logs from containerized processes.\n\nLack of logging makes **incident investigation**, **compliance auditing**, and **security monitoring** significantly more difficult.", + "Description": "**Alibaba Cloud Log Service** supports collection, search, storage, and analysis for container and audit logs in **Kubernetes Engine clusters**. When enabled, it automatically collects `kube-apiserver` audit logs, ingress logs, and stdout/stderr from containers. These logs are stored persistently and are essential for operational visibility, security monitoring, and compliance auditing.", + "Risk": "Without **Log Service**, there is no centralized collection of container logs or cluster events, impairing **incident investigation**, **compliance auditing**, and **security monitoring**. Attackers could operate undetected with no audit trail of API server calls or pod events, impacting cluster **confidentiality** and **integrity**.", "RelatedUrl": "", "AdditionalURLs": [ - "https://help.aliyun.com/document_detail/91406.html", - "https://help.aliyun.com/document_detail/86532.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-log-service.html" + "https://www.alibabacloud.com/help/en/ack/", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-log-service.html" ], "Remediation": { "Code": { - "CLI": "aliyun cs GET /clusters/[cluster_id] to verify AuditProjectName is set. When creating a new cluster, set Enable Log Service to Enabled.", + "CLI": "aliyun cs GET /clusters/ --header 'Content-Type=application/json' | jq '.meta_data' | jq -r 'fromjson | .AuditProjectName'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ACK Console**.\n2. Select the target cluster and click its name to open the cluster detail page.\n3. Select **Cluster Auditing** on the left column and check if the audit page is shown.\n4. To enable: When creating a new cluster, set `Enable Log Service` to **Enabled**.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **ACK Console**\n2. Select the target cluster and click its name to open the cluster detail page\n3. Select **Cluster Auditing** on the left column and check if the audit page is shown\n4. To enable: When creating a new cluster, set `Enable Log Service` to **Enabled**", + "Text": "Enable **Log Service** during cluster creation by setting `Enable Log Service` to **Enabled**. For existing clusters, verify that `AuditProjectName` is configured in the cluster metadata.", "Url": "https://hub.prowler.com/check/cs_kubernetes_log_service_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_network_policy_enabled/cs_kubernetes_network_policy_enabled.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_network_policy_enabled/cs_kubernetes_network_policy_enabled.metadata.json index e7fddd4694..a0a40210cb 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_network_policy_enabled/cs_kubernetes_network_policy_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_network_policy_enabled/cs_kubernetes_network_policy_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_network_policy_enabled", - "CheckTitle": "Network policy is enabled on Kubernetes Engine Clusters", - "CheckType": [ - "Threat detection during container runtime", - "Suspicious network connection" - ], + "CheckTitle": "Kubernetes cluster has Network policy enabled", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "A **Network Policy** is a specification of how groups of pods are allowed to communicate with each other and other network endpoints.\n\n`NetworkPolicy` resources use labels to select pods and define rules which specify what traffic is allowed. By default, pods are non-isolated and accept traffic from any source. Pods become isolated by having a NetworkPolicy that selects them.", - "Risk": "Without **Network Policies**, all pods in a Kubernetes cluster can communicate with each other freely. This open communication model allows an attacker who compromises a single pod to potentially move **laterally** within the cluster and access sensitive services or data.\n\nNetwork Policies are essential for implementing **defense in depth** and **least privilege** networking.", + "Description": "**Alibaba Cloud Kubernetes Engine** clusters should enable **Network Policy** via the **Terway** plugin. A `NetworkPolicy` defines how pods communicate using label-based rules. By default, pods accept traffic from any source; NetworkPolicy restricts traffic to explicitly allowed connections, enforcing least privilege at the network level.", + "Risk": "Without **Network Policies**, all pods communicate freely, creating an unrestricted flat network. An attacker who compromises a single pod can move **laterally**, accessing sensitive services, databases, and secrets. The absence of network segmentation undermines **defense in depth** and increases the blast radius of any compromise, impacting **confidentiality** and **integrity** of workloads.", "RelatedUrl": "", "AdditionalURLs": [ - "https://help.aliyun.com/document_detail/97621.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-network-policy-support.html" + "https://www.alibabacloud.com/help/en/ack/", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-network-policy-support.html" ], "Remediation": { "Code": { - "CLI": "Network Policy support (Terway) must be selected during cluster creation.", + "CLI": "aliyun cs GET /clusters/ --header 'Content-Type=application/json' | jq '.parameters.Network'", "NativeIaC": "", - "Other": "", + "Other": "1. When creating a new cluster in the **ACK Console**, select **Terway** in the `Network Plugin` option to enable Network Policy support.\n2. Note that existing clusters using **Flannel** cannot be migrated to **Terway**.", "Terraform": "" }, "Recommendation": { - "Text": "Only the **Terway** network plugin supports the Network Policy feature. When creating a new cluster, select **Terway** in the `Network Plugin` option.\n\n**Note:** Existing clusters using Flannel cannot be migrated to Terway.", + "Text": "Select the **Terway** network plugin during cluster creation to enable Network Policy support. Existing clusters using **Flannel** cannot be migrated to Terway and must be recreated.", "Url": "https://hub.prowler.com/check/cs_kubernetes_network_policy_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_private_cluster_enabled/cs_kubernetes_private_cluster_enabled.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_private_cluster_enabled/cs_kubernetes_private_cluster_enabled.metadata.json index 4ea2cf3893..26eb8ea727 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_private_cluster_enabled/cs_kubernetes_private_cluster_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_private_cluster_enabled/cs_kubernetes_private_cluster_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_private_cluster_enabled", - "CheckTitle": "Kubernetes Cluster is created with Private cluster enabled", - "CheckType": [ - "Threat detection during container runtime", - "Unusual logon" - ], + "CheckTitle": "Kubernetes cluster is created with private cluster enabled", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "A **private cluster** is a cluster that makes your master inaccessible from the public internet.\n\nIn a private cluster, nodes do not have public IP addresses, so your workloads run in an environment that is isolated from the internet. Nodes and masters communicate with each other privately using **VPC peering**.", - "Risk": "Exposing the **API server endpoint** to the public internet increases the attack surface of your cluster. Attackers can attempt to probe for vulnerabilities, perform **brute force attacks**, or exploit misconfigurations if the API server is publicly accessible.\n\nUsing a private cluster significantly reduces network security risks.", + "Description": "**Alibaba Cloud Kubernetes Engine** clusters should be configured as **private clusters** so the API server endpoint is not publicly accessible. In a private cluster, nodes lack public IPs and all node-to-master communication occurs through **VPC peering**. This reduces the attack surface by eliminating direct internet exposure of the control plane and worker nodes.", + "Risk": "A public **API server endpoint** allows attackers to probe for vulnerabilities, perform **brute force attacks**, or exploit misconfigurations. Automated scanners and botnets can target it, increasing risk of unauthorized access. This impacts **confidentiality** and **integrity** by potentially allowing attackers to execute commands, deploy malicious workloads, or exfiltrate data.", "RelatedUrl": "", "AdditionalURLs": [ - "https://help.aliyun.com/document_detail/100380.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/private-cluster.html" + "https://www.alibabacloud.com/help/en/ack/", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/private-cluster.html" ], "Remediation": { "Code": { - "CLI": "Public access settings cannot be easily changed for existing clusters. Ensure Public Access is disabled during creation.", + "CLI": "aliyun cs GET /clusters/ --header 'Content-Type=application/json' | jq '.external_loadbalancer_id'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ACK Console**.\n2. Select the target cluster name and go to the cluster detail page.\n3. Check if there is no `API Server Public Network Endpoint` under Cluster Information.\n4. When creating a new cluster, make sure **Public Access** is not enabled.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **ACK Console**\n2. Select the target cluster name and go to the cluster detail page\n3. Check if there is no `API Server Public Network Endpoint` under Cluster Information\n4. When creating a new cluster, make sure **Public Access** is not enabled", + "Text": "Disable **Public Access** during cluster creation to ensure the API server is not exposed to the public internet. For existing clusters, remove the public endpoint if one was configured.", "Url": "https://hub.prowler.com/check/cs_kubernetes_private_cluster_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_rbac_enabled/cs_kubernetes_rbac_enabled.metadata.json b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_rbac_enabled/cs_kubernetes_rbac_enabled.metadata.json index bd4fa293ce..67312022de 100644 --- a/prowler/providers/alibabacloud/services/cs/cs_kubernetes_rbac_enabled/cs_kubernetes_rbac_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/cs/cs_kubernetes_rbac_enabled/cs_kubernetes_rbac_enabled.metadata.json @@ -1,34 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "cs_kubernetes_rbac_enabled", - "CheckTitle": "Role-based access control (RBAC) authorization is Enabled on Kubernetes Engine Clusters", - "CheckType": [ - "Threat detection during container runtime", - "Abnormal account" - ], + "CheckTitle": "Kubernetes cluster has RBAC authorization enabled", + "CheckType": [], "ServiceName": "cs", "SubServiceName": "", - "ResourceIdTemplate": "acs:cs:region:account-id:cluster/{cluster-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudKubernetesCluster", + "ResourceType": "ALIYUN::CS::ManagedKubernetesCluster", "ResourceGroup": "container", - "Description": "In Kubernetes, authorizers interact by granting a permission if any authorizer grants the permission. The legacy authorizer in Kubernetes Engine grants broad, statically defined permissions.\n\nTo ensure that **RBAC** limits permissions correctly, you must disable the legacy authorizer. RBAC has significant security advantages, helps ensure that users only have access to specific cluster resources within their own namespace, and is now stable in Kubernetes.", - "Risk": "In Kubernetes, **RBAC** is used to grant permissions to resources at the cluster and namespace level. RBAC allows you to define roles with rules containing a set of permissions.\n\nWithout RBAC, legacy authorization mechanisms like **ABAC** grant **overly broad permissions**, increasing the risk of unauthorized access and privilege escalation.", + "Description": "**Alibaba Cloud Kubernetes Engine** clusters should have **RBAC** enabled for fine-grained authorization. RBAC lets administrators define roles with specific permissions at cluster and namespace level, ensuring users and service accounts access only needed resources. Legacy **ABAC** grants broad, static permissions and should be disabled in favor of RBAC.", + "Risk": "Without **RBAC**, clusters may rely on legacy **ABAC** which grants **overly broad permissions** that cannot be scoped to specific namespaces or resources. This increases the risk of **unauthorized access** and **privilege escalation**, where a compromised account could access sensitive resources across the cluster, impacting **confidentiality** and **integrity**.", "RelatedUrl": "", "AdditionalURLs": [ - "https://help.aliyun.com/document_detail/87656.html", - "https://help.aliyun.com/document_detail/119596.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-rbac-authorization.html" + "https://www.alibabacloud.com/help/en/ack/", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ACK/enable-rbac-authorization.html" ], "Remediation": { "Code": { - "CLI": "RBAC is enabled by default on new ACK clusters. Verify cluster authorization configuration.", + "CLI": "aliyun cs GET /clusters/ --header 'Content-Type=application/json' | jq '.parameters.KubernetesVersion'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ACK Console**.\n2. Navigate to **Clusters** -> **Authorizations** page.\n3. Select the target RAM sub-account and configure the RBAC roles on specific clusters or namespaces.\n4. Ensure **RBAC** is enabled and legacy ABAC authorization is disabled.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **ACK Console**\n2. Navigate to **Clusters** -> **Authorizations** page\n3. Select the target RAM sub-account and configure the RBAC roles on specific clusters or namespaces\n4. Ensure **RBAC** is enabled and legacy ABAC authorization is disabled", + "Text": "Ensure **RBAC** is enabled on all Kubernetes Engine clusters and that legacy **ABAC** authorization is disabled. Configure RBAC roles and bindings through the ACK Console Authorizations page to enforce least-privilege access.", "Url": "https://hub.prowler.com/check/cs_kubernetes_rbac_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_attached_disk_encrypted/ecs_attached_disk_encrypted.metadata.json b/prowler/providers/alibabacloud/services/ecs/ecs_attached_disk_encrypted/ecs_attached_disk_encrypted.metadata.json index b6d14c101b..cc8acd56c8 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_attached_disk_encrypted/ecs_attached_disk_encrypted.metadata.json +++ b/prowler/providers/alibabacloud/services/ecs/ecs_attached_disk_encrypted/ecs_attached_disk_encrypted.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ecs_attached_disk_encrypted", - "CheckTitle": "Virtual Machines disk are encrypted", - "CheckType": [ - "Sensitive file tampering" - ], + "CheckTitle": "ECS attached disk is encrypted", + "CheckType": [], "ServiceName": "ecs", "SubServiceName": "", - "ResourceIdTemplate": "acs:ecs:region:account-id:disk/{disk-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudECSDisk", + "ResourceType": "ALIYUN::ECS::Disk", "ResourceGroup": "storage", - "Description": "**ECS cloud disk encryption** protects your data at rest. The cloud disk data encryption feature automatically encrypts data when data is transferred from ECS instances to disks, and decrypts data when read from disks.\n\nEnsure that disks are encrypted when they are created with the creation of VM instances.", - "Risk": "**Unencrypted disks** attached to ECS instances pose a security risk as they may contain sensitive data that could be accessed if the disk is compromised or accessed by unauthorized parties.\n\nData at rest without encryption is vulnerable to **unauthorized access** if storage media is lost, stolen, or improperly decommissioned.", + "Description": "**Alibaba Cloud ECS disk encryption** protects data at rest by automatically encrypting data transferred to disks and decrypting when read. Ensuring all attached disks are encrypted prevents unauthorized access to stored data. This check verifies **disk encryption** is enabled on all ECS disks attached to instances, using **KMS** for key management.", + "Risk": "**Unencrypted disks** attached to ECS instances pose a significant security risk, as sensitive data could be exposed if the disk is compromised, improperly decommissioned, or accessed by unauthorized parties. Data at rest without encryption is vulnerable to **unauthorized access**, impacting **confidentiality** and potentially leading to **data breaches** or **regulatory non-compliance**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/59643.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/encrypt-vm-instance-disks.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/encrypt-vm-instance-disks.html" ], "Remediation": { "Code": { "CLI": "aliyun ecs CreateDisk --DiskName --Size --Encrypted true --KmsKeyId ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ECS Console** > **Instances & Images** > **Images**.\n2. Select the **Custom Image** tab and select the target image.\n3. Click **Copy Image** and check the **Encrypt** box.\n4. Select a key and click **OK**.\n5. For data disks, go to **Instances** > **Create Instance**, in the Storage section click **Add Disk**, select **Disk Encryption**, and choose a key.\n\n**Note:** You cannot directly convert unencrypted disks to encrypted disks.", "Terraform": "resource \"alicloud_ecs_disk\" \"encrypted\" {\n zone_id = \"cn-hangzhou-a\"\n disk_name = \"encrypted-disk\"\n category = \"cloud_efficiency\"\n size = 20\n encrypted = true\n kms_key_id = alicloud_kms_key.example.id\n}" }, "Recommendation": { - "Text": "**Encrypt a system disk when copying an image:**\n1. Log on to the **ECS Console** > **Instances & Images** > **Images**\n2. Select the **Custom Image** tab and select target image\n3. Click **Copy Image** and check the **Encrypt** box\n4. Select a key and click **OK**\n\n**Encrypt a data disk when creating an instance:**\n1. Log on to the **ECS Console** > **Instances & Images** > **Instances** > **Create Instance**\n2. In the Storage section, click **Add Disk**\n3. Select **Disk Encryption** and choose a key\n\n**Note:** You cannot directly convert unencrypted disks to encrypted disks.", + "Text": "Enable encryption on all ECS disks to protect data at rest. Use KMS-managed keys for encryption. Note that existing unencrypted disks cannot be directly converted; data must be migrated to new encrypted disks.", "Url": "https://hub.prowler.com/check/ecs_attached_disk_encrypted" } }, diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_instance_endpoint_protection_installed/ecs_instance_endpoint_protection_installed.metadata.json b/prowler/providers/alibabacloud/services/ecs/ecs_instance_endpoint_protection_installed/ecs_instance_endpoint_protection_installed.metadata.json index d2ad113899..5ae20ef78a 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_instance_endpoint_protection_installed/ecs_instance_endpoint_protection_installed.metadata.json +++ b/prowler/providers/alibabacloud/services/ecs/ecs_instance_endpoint_protection_installed/ecs_instance_endpoint_protection_installed.metadata.json @@ -1,35 +1,29 @@ { "Provider": "alibabacloud", "CheckID": "ecs_instance_endpoint_protection_installed", - "CheckTitle": "The endpoint protection for all Virtual Machines is installed", - "CheckType": [ - "Suspicious process", - "Webshell", - "Unusual logon", - "Sensitive file tampering", - "Malicious software" - ], + "CheckTitle": "ECS instance has endpoint protection installed", + "CheckType": [], "ServiceName": "ecs", "SubServiceName": "", - "ResourceIdTemplate": "acs:ecs:region:account-id:instance/{instance-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudECSInstance", + "ResourceType": "ALIYUN::ECS::Instance", "ResourceGroup": "compute", - "Description": "Installing **endpoint protection systems** (like **Security Center** for Alibaba Cloud) provides real-time protection capability that helps identify and remove viruses, spyware, and other malicious software.\n\nConfigurable alerts notify when known malicious software attempts to install itself or run on ECS instances.", - "Risk": "ECS instances without **endpoint protection** are vulnerable to **malware**, **viruses**, and other security threats.\n\nEndpoint protection provides real-time monitoring and protection capabilities essential for detecting and preventing security incidents.", + "Description": "**Alibaba Cloud Security Center** provides endpoint protection for ECS instances with real-time detection and removal of malicious software. This check verifies the **Security Center agent** is installed and active on all ECS instances, ensuring alerts notify administrators when malicious software attempts to install or execute.", + "Risk": "ECS instances without **endpoint protection** are vulnerable to **malware**, **viruses**, **webshells**, and other security threats that can compromise **confidentiality**, **integrity**, and **availability**. Without real-time monitoring, security incidents may go undetected, allowing attackers to maintain persistent access and exfiltrate sensitive data.", "RelatedUrl": "", "AdditionalURLs": [ - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/enable-endpoint-protection.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/enable-endpoint-protection.html" ], "Remediation": { "Code": { - "CLI": "Logon to Security Center Console > Select Settings > Click Agent > Select virtual machines without Security Center agent > Click Install", + "CLI": "aliyun sas InstallBackupClient --Uuid ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **Security Center Console**.\n2. Select **Settings**.\n3. Click **Agent**.\n4. On the Agent tab, select the virtual machines without Security Center agent installed.\n5. Click **Install**.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **Security Center Console**\n2. Select **Settings**\n3. Click **Agent**\n4. On the Agent tab, select the virtual machines without Security Center agent installed\n5. Click **Install**", + "Text": "Install the Alibaba Cloud **Security Center** agent on all ECS instances to enable real-time endpoint protection, malware detection, and vulnerability scanning.", "Url": "https://hub.prowler.com/check/ecs_instance_endpoint_protection_installed" } }, diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_instance_latest_os_patches_applied/ecs_instance_latest_os_patches_applied.metadata.json b/prowler/providers/alibabacloud/services/ecs/ecs_instance_latest_os_patches_applied/ecs_instance_latest_os_patches_applied.metadata.json index 21222b9058..d667848b18 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_instance_latest_os_patches_applied/ecs_instance_latest_os_patches_applied.metadata.json +++ b/prowler/providers/alibabacloud/services/ecs/ecs_instance_latest_os_patches_applied/ecs_instance_latest_os_patches_applied.metadata.json @@ -1,32 +1,29 @@ { "Provider": "alibabacloud", "CheckID": "ecs_instance_latest_os_patches_applied", - "CheckTitle": "The latest OS Patches for all Virtual Machines are applied", - "CheckType": [ - "Malicious software", - "Web application threat detection" - ], + "CheckTitle": "ECS instance has latest OS patches applied", + "CheckType": [], "ServiceName": "ecs", "SubServiceName": "", - "ResourceIdTemplate": "acs:ecs:region:account-id:instance/{instance-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudECSInstance", + "ResourceType": "ALIYUN::ECS::Instance", "ResourceGroup": "compute", - "Description": "Windows and Linux virtual machines should be kept updated to address specific bugs or flaws, improve OS or application's general stability, and fix **security vulnerabilities**.\n\nThe Alibaba Cloud **Security Center** checks for the latest updates in Linux and Windows systems.", - "Risk": "**Unpatched systems** are vulnerable to known security exploits and may be compromised by attackers.\n\nKeeping systems updated with the latest patches is critical for maintaining security and preventing **exploitation of known vulnerabilities**.", + "Description": "**Alibaba Cloud Security Center** checks for the latest updates in Linux and Windows systems running on ECS instances. Keeping virtual machines updated with the latest OS patches addresses specific bugs, improves general stability, and fixes **security vulnerabilities**. This check verifies that all known vulnerabilities detected by Security Center have been patched on each ECS instance.", + "Risk": "**Unpatched systems** are vulnerable to known security exploits and can be compromised by attackers leveraging publicly disclosed vulnerabilities. Failure to apply patches in a timely manner increases the risk of **unauthorized access**, **malware infection**, and **data breaches**, impacting the **confidentiality**, **integrity**, and **availability** of the system.", "RelatedUrl": "", "AdditionalURLs": [ - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/apply-latest-os-patches.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/apply-latest-os-patches.html" ], "Remediation": { "Code": { - "CLI": "Logon to Security Center Console > Select Vulnerabilities > Apply all patches for vulnerabilities", + "CLI": "aliyun sas FixCheckWarnings --CheckIds ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **Security Center Console**.\n2. Select **Vulnerabilities** in the left-side navigation pane.\n3. Review all detected vulnerabilities.\n4. Apply all available patches for the reported vulnerabilities.\n5. Verify that vulnerabilities are resolved after patching.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **Security Center Console**\n2. Select **Vulnerabilities**\n3. Ensure all vulnerabilities are fixed\n4. Apply all patches for vulnerabilities", + "Text": "Regularly review and apply OS patches on all ECS instances using the **Alibaba Cloud Security Center** vulnerability management feature to maintain a strong security posture.", "Url": "https://hub.prowler.com/check/ecs_instance_latest_os_patches_applied" } }, diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_instance_no_legacy_network/ecs_instance_no_legacy_network.metadata.json b/prowler/providers/alibabacloud/services/ecs/ecs_instance_no_legacy_network/ecs_instance_no_legacy_network.metadata.json index 1d3eee3847..5da71e02cf 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_instance_no_legacy_network/ecs_instance_no_legacy_network.metadata.json +++ b/prowler/providers/alibabacloud/services/ecs/ecs_instance_no_legacy_network/ecs_instance_no_legacy_network.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ecs_instance_no_legacy_network", - "CheckTitle": "Legacy networks does not exist", - "CheckType": [ - "Suspicious network connection" - ], + "CheckTitle": "ECS instance does not use legacy network", + "CheckType": [], "ServiceName": "ecs", "SubServiceName": "", - "ResourceIdTemplate": "acs:ecs:region:account-id:instance/{instance-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudECSInstance", + "ResourceType": "ALIYUN::ECS::Instance", "ResourceGroup": "compute", - "Description": "In order to prevent use of **legacy networks**, ECS instances should not have a legacy network configured.\n\nLegacy networks have a single network IPv4 prefix range and a single gateway IP address for the whole network. With legacy networks, you cannot create subnetworks or switch from legacy to auto or custom subnet networks.", - "Risk": "**Legacy networks** can have an impact on high network traffic ECS instances and are subject to a **single point of failure**.\n\nThey also lack the security isolation and network segmentation capabilities provided by **VPCs**.", + "Description": "**Alibaba Cloud ECS instances** should use **VPC (Virtual Private Cloud)** networks instead of legacy classic networks. Legacy networks have a single IPv4 prefix range and a single gateway IP address for the whole network, preventing the creation of subnetworks or migration to auto/custom subnet networks. This check verifies that no ECS instances are configured with a legacy network type.", + "Risk": "**Legacy networks** lack the security isolation and network segmentation capabilities provided by **VPCs**, creating a **single point of failure** for high-traffic instances. Without proper network segmentation, lateral movement by attackers becomes easier, impacting **confidentiality** and **integrity** of workloads sharing the same flat network.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/87190.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-VPC/legacy-network-usage.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-VPC/legacy-network-usage.html" ], "Remediation": { "Code": { "CLI": "aliyun ecs CreateInstance --InstanceName --ImageId --InstanceType --VSwitchId ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ECS Console**.\n2. In the left-side navigation pane, choose **Instance & Image** > **Instances**.\n3. Click **Create Instance**.\n4. Specify the basic instance information and click **Next: Networking**.\n5. Select **VPC** as the Network Type and choose an appropriate VSwitch.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **ECS Console**\n2. In the left-side navigation pane, choose **Instance & Image** > **Instances**\n3. Click **Create Instance**\n4. Specify the basic instance information required and click **Next: Networking**\n5. Select the Network Type of **VPC**", + "Text": "Migrate all ECS instances from legacy classic networks to **VPC** networks. Create new instances within a VPC and migrate workloads from legacy network instances.", "Url": "https://hub.prowler.com/check/ecs_instance_no_legacy_network" } }, diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.metadata.json b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.metadata.json index cf61b5255e..855d63279f 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.metadata.json +++ b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_rdp_internet/ecs_securitygroup_restrict_rdp_internet.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ecs_securitygroup_restrict_rdp_internet", - "CheckTitle": "RDP access is restricted from the internet", - "CheckType": [ - "Unusual logon", - "Suspicious network connection" - ], + "CheckTitle": "Security group restricts RDP access from the internet", + "CheckType": [], "ServiceName": "ecs", "SubServiceName": "", - "ResourceIdTemplate": "acs:ecs:region:account-id:security-group/{security-group-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudECSSecurityGroup", + "ResourceType": "ALIYUN::ECS::SecurityGroup", "ResourceGroup": "network", - "Description": "**Security groups** provide stateful filtering of ingress/egress network traffic to Alibaba Cloud resources.\n\nIt is recommended that no security group allows unrestricted ingress access to port **3389 (RDP)**.", - "Risk": "Removing unfettered connectivity to remote console services, such as **RDP**, reduces a server's exposure to risk.\n\nUnrestricted RDP access from the internet (`0.0.0.0/0`) exposes systems to **brute force attacks**, **credential stuffing**, and **exploitation of RDP vulnerabilities**.", + "Description": "**Alibaba Cloud ECS security groups** provide stateful filtering of ingress and egress network traffic to cloud resources. This check verifies that no security group allows unrestricted ingress access to port **3389** (RDP) from the internet (`0.0.0.0/0` or `::/0`). Restricting RDP access to trusted IP addresses significantly reduces the attack surface of ECS instances.", + "Risk": "Unrestricted **RDP access** from the internet (`0.0.0.0/0`) exposes systems to **brute force attacks**, **credential stuffing**, and **exploitation of RDP vulnerabilities** such as BlueKeep. This can lead to **unauthorized access**, **data exfiltration**, and full system compromise, impacting **confidentiality**, **integrity**, and **availability**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/25387.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/unrestricted-rdp-access.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/unrestricted-rdp-access.html" ], "Remediation": { "Code": { "CLI": "aliyun ecs RevokeSecurityGroup --SecurityGroupId --IpProtocol tcp --PortRange 3389/3389 --SourceCidrIp 0.0.0.0/0", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ECS Console**.\n2. In the left-side navigation pane, choose **Network & Security** > **Security Groups**.\n3. Find the target security group and click **Add Rules**.\n4. Locate the rule allowing port `3389` from `0.0.0.0/0`.\n5. Modify the Source IP range to a specific trusted IP or CIDR block.\n6. Click **Save**.", "Terraform": "resource \"alicloud_security_group_rule\" \"deny_rdp_internet\" {\n type = \"ingress\"\n ip_protocol = \"tcp\"\n port_range = \"3389/3389\"\n security_group_id = alicloud_security_group.example.id\n cidr_ip = \"10.0.0.0/8\" # Restrict to internal network\n policy = \"accept\"\n}" }, "Recommendation": { - "Text": "1. Log on to the **ECS Console**\n2. In the left-side navigation pane, choose **Network & Security** > **Security Groups**\n3. Find the Security Group you want to modify\n4. Modify Source IP range to specific IP instead of `0.0.0.0/0`\n5. Click **Save**", + "Text": "Restrict RDP (port **3389**) access in security groups to only trusted IP addresses or CIDR blocks. Remove any rules allowing access from `0.0.0.0/0` or `::/0`.", "Url": "https://hub.prowler.com/check/ecs_securitygroup_restrict_rdp_internet" } }, diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.metadata.json b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.metadata.json index 136687ed7f..19c2b5e7f3 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.metadata.json +++ b/prowler/providers/alibabacloud/services/ecs/ecs_securitygroup_restrict_ssh_internet/ecs_securitygroup_restrict_ssh_internet.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ecs_securitygroup_restrict_ssh_internet", - "CheckTitle": "SSH access is restricted from the internet", - "CheckType": [ - "Unusual logon", - "Suspicious network connection" - ], + "CheckTitle": "Security group restricts SSH access from the internet", + "CheckType": [], "ServiceName": "ecs", "SubServiceName": "", - "ResourceIdTemplate": "acs:ecs:region:account-id:security-group/{security-group-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudECSSecurityGroup", + "ResourceType": "ALIYUN::ECS::SecurityGroup", "ResourceGroup": "network", - "Description": "**Security groups** provide stateful filtering of ingress/egress network traffic to Alibaba Cloud resources.\n\nIt is recommended that no security group allows unrestricted ingress access to port **22 (SSH)**.", - "Risk": "Removing unfettered connectivity to remote console services, such as **SSH**, reduces a server's exposure to risk.\n\nUnrestricted SSH access from the internet (`0.0.0.0/0`) exposes systems to **brute force attacks**, **credential stuffing**, and **exploitation of SSH vulnerabilities**.", + "Description": "**Alibaba Cloud ECS security groups** provide stateful filtering of ingress and egress network traffic to cloud resources. This check verifies that no security group allows unrestricted ingress access to port **22** (SSH) from the internet (`0.0.0.0/0` or `::/0`). Restricting SSH access to trusted IP addresses significantly reduces the attack surface of ECS instances.", + "Risk": "Unrestricted **SSH access** from the internet (`0.0.0.0/0`) exposes systems to **brute force attacks**, **credential stuffing**, and **exploitation of SSH vulnerabilities**. This can lead to **unauthorized access**, **data exfiltration**, and full system compromise, impacting **confidentiality**, **integrity**, and **availability**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/25387.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/unrestricted-ssh-access.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/unrestricted-ssh-access.html" ], "Remediation": { "Code": { "CLI": "aliyun ecs RevokeSecurityGroup --SecurityGroupId --IpProtocol tcp --PortRange 22/22 --SourceCidrIp 0.0.0.0/0", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ECS Console**.\n2. In the left-side navigation pane, choose **Network & Security** > **Security Groups**.\n3. Find the target security group and click **Add Rules**.\n4. Locate the rule allowing port `22` from `0.0.0.0/0`.\n5. Modify the Source IP range to a specific trusted IP or CIDR block.\n6. Click **Save**.", "Terraform": "resource \"alicloud_security_group_rule\" \"deny_ssh_internet\" {\n type = \"ingress\"\n ip_protocol = \"tcp\"\n port_range = \"22/22\"\n security_group_id = alicloud_security_group.example.id\n cidr_ip = \"10.0.0.0/8\" # Restrict to internal network\n policy = \"accept\"\n}" }, "Recommendation": { - "Text": "1. Log on to the **ECS Console**\n2. In the left-side navigation pane, choose **Network & Security** > **Security Groups**\n3. Find the Security Group you want to modify\n4. Modify Source IP range to specific IP instead of `0.0.0.0/0`\n5. Click **Save**", + "Text": "Restrict SSH (port **22**) access in security groups to only trusted IP addresses or CIDR blocks. Remove any rules allowing access from `0.0.0.0/0` or `::/0`.", "Url": "https://hub.prowler.com/check/ecs_securitygroup_restrict_ssh_internet" } }, diff --git a/prowler/providers/alibabacloud/services/ecs/ecs_unattached_disk_encrypted/ecs_unattached_disk_encrypted.metadata.json b/prowler/providers/alibabacloud/services/ecs/ecs_unattached_disk_encrypted/ecs_unattached_disk_encrypted.metadata.json index ed2daedbde..0808a219f0 100644 --- a/prowler/providers/alibabacloud/services/ecs/ecs_unattached_disk_encrypted/ecs_unattached_disk_encrypted.metadata.json +++ b/prowler/providers/alibabacloud/services/ecs/ecs_unattached_disk_encrypted/ecs_unattached_disk_encrypted.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ecs_unattached_disk_encrypted", - "CheckTitle": "Unattached disks are encrypted", - "CheckType": [ - "Sensitive file tampering" - ], + "CheckTitle": "ECS unattached disk is encrypted", + "CheckType": [], "ServiceName": "ecs", "SubServiceName": "", - "ResourceIdTemplate": "acs:ecs:region:account-id:disk/{disk-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudECSDisk", + "ResourceType": "ALIYUN::ECS::Disk", "ResourceGroup": "storage", - "Description": "**Cloud disk encryption** protects your data at rest. The cloud disk data encryption feature automatically encrypts data when data is transferred from ECS instances to disks, and decrypts data when read from disks.", - "Risk": "**Unencrypted unattached disks** pose a security risk as they may contain sensitive data that could be accessed if the disk is compromised or accessed by unauthorized parties.\n\nUnattached disks are especially vulnerable as they may be forgotten or not monitored, increasing the risk of **unauthorized access**.", + "Description": "**Alibaba Cloud ECS cloud disk encryption** protects data at rest by automatically encrypting data when it is transferred from ECS instances to disks and decrypting it when read. This check verifies that unattached (detached) disks have encryption enabled, since unattached disks may still contain sensitive data from previous workloads and are especially vulnerable if not properly managed.", + "Risk": "**Unencrypted unattached disks** pose a significant security risk as they may contain sensitive data that could be accessed if the disk is compromised or accessed by unauthorized parties. Unattached disks are especially vulnerable as they may be overlooked in security monitoring, increasing the risk of **unauthorized access** and **data breaches** that impact **confidentiality**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/59643.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/encrypt-unattached-disks.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-ECS/encrypt-unattached-disks.html" ], "Remediation": { "Code": { "CLI": "aliyun ecs CreateDisk --DiskName --Size --Encrypted true --KmsKeyId ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **ECS Console**.\n2. In the left-side navigation pane, choose **Storage & Snapshots** > **Disk**.\n3. In the upper-right corner of the Disks page, click **Create Disk**.\n4. In the Disk section, check the **Disk Encryption** box and select a key from the drop-down list.\n\n**Note:** After a data disk is created, you can only encrypt it by manually copying data from the unencrypted disk to a new encrypted disk.", "Terraform": "resource \"alicloud_ecs_disk\" \"encrypted\" {\n zone_id = \"cn-hangzhou-a\"\n disk_name = \"encrypted-disk\"\n category = \"cloud_efficiency\"\n size = 20\n encrypted = true\n kms_key_id = alicloud_kms_key.example.id\n}" }, "Recommendation": { - "Text": "1. Log on to the **ECS Console**\n2. In the left-side navigation pane, choose **Storage & Snapshots** > **Disk**\n3. In the upper-right corner of the Disks page, click **Create Disk**\n4. In the Disk section, check the **Disk Encryption** box and select a key from the drop-down list\n\n**Note:** After a data disk is created, you can only encrypt the data disk by manually copying data from the unencrypted disk to a new encrypted disk.", + "Text": "Ensure all unattached ECS disks are encrypted. Create new encrypted disks and migrate data from unencrypted disks, then delete the unencrypted originals.", "Url": "https://hub.prowler.com/check/ecs_unattached_disk_encrypted" } }, diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_logging_enabled/oss_bucket_logging_enabled.metadata.json b/prowler/providers/alibabacloud/services/oss/oss_bucket_logging_enabled/oss_bucket_logging_enabled.metadata.json index 23fce0f862..934eee4dbf 100644 --- a/prowler/providers/alibabacloud/services/oss/oss_bucket_logging_enabled/oss_bucket_logging_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/oss/oss_bucket_logging_enabled/oss_bucket_logging_enabled.metadata.json @@ -2,32 +2,29 @@ "Provider": "alibabacloud", "CheckID": "oss_bucket_logging_enabled", "CheckTitle": "Logging is enabled for OSS buckets", - "CheckType": [ - "Sensitive file tampering", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "oss", "SubServiceName": "", - "ResourceIdTemplate": "acs:oss::account-id:bucket-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudOSSBucket", + "ResourceType": "ALIYUN::OSS::Bucket", "ResourceGroup": "storage", - "Description": "**OSS Bucket Access Logging** generates a log that contains access records for each request made to your OSS bucket.\n\nAn access log record contains details about the request, such as the request type, the resources specified in the request, and the time and date the request was processed. It is recommended that bucket access logging be enabled on OSS buckets.", - "Risk": "By enabling **OSS bucket logging** on target OSS buckets, it is possible to capture all events which may affect objects within target buckets.\n\nConfiguring logs to be placed in a separate bucket allows access to log information useful in **security** and **incident response** workflows.", + "Description": "**Alibaba Cloud OSS Bucket Access Logging** generates a log record for each request made to your OSS bucket, containing details such as the request type, the resources specified, and the time and date the request was processed. Enabling bucket access logging on all OSS buckets ensures that access patterns are recorded and available for security analysis and incident response workflows.", + "Risk": "Without **OSS bucket logging** enabled, access events affecting objects within target buckets are not captured. This limits the ability to perform **security analysis**, **incident response**, and **forensic investigations**, as there is no record of who accessed or modified stored data.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/31900.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-OSS/enable-bucket-access-logging.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-OSS/enable-bucket-access-logging.html" ], "Remediation": { "Code": { "CLI": "ossutil logging --method put oss:// --target-bucket --target-prefix ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Under **Log**, click **Configure**\n4. Click the **Enabled** checkbox\n5. Select `Target Bucket` from the list\n6. Enter a `Target Prefix`\n7. Click **Save**", "Terraform": "resource \"alicloud_oss_bucket_logging\" \"example\" {\n bucket = alicloud_oss_bucket.example.bucket\n target_bucket = alicloud_oss_bucket.log_bucket.bucket\n target_prefix = \"log/\"\n}" }, "Recommendation": { - "Text": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Under **Log**, click **Configure**\n4. Click the **Enabled** checkbox\n5. Select `Target Bucket` from the list\n6. Enter a `Target Prefix`\n7. Click **Save**", + "Text": "Enable access logging on all OSS buckets and configure logs to be stored in a separate dedicated bucket for security analysis and compliance auditing.", "Url": "https://hub.prowler.com/check/oss_bucket_logging_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_not_publicly_accessible/oss_bucket_not_publicly_accessible.metadata.json b/prowler/providers/alibabacloud/services/oss/oss_bucket_not_publicly_accessible/oss_bucket_not_publicly_accessible.metadata.json index 8ba7ef19db..748e14b666 100644 --- a/prowler/providers/alibabacloud/services/oss/oss_bucket_not_publicly_accessible/oss_bucket_not_publicly_accessible.metadata.json +++ b/prowler/providers/alibabacloud/services/oss/oss_bucket_not_publicly_accessible/oss_bucket_not_publicly_accessible.metadata.json @@ -2,32 +2,29 @@ "Provider": "alibabacloud", "CheckID": "oss_bucket_not_publicly_accessible", "CheckTitle": "OSS bucket is not anonymously or publicly accessible", - "CheckType": [ - "Sensitive file tampering", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "oss", "SubServiceName": "", - "ResourceIdTemplate": "acs:oss::account-id:bucket-name", + "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "AlibabaCloudOSSBucket", + "ResourceType": "ALIYUN::OSS::Bucket", "ResourceGroup": "storage", - "Description": "A bucket is a container used to store objects in **Object Storage Service (OSS)**. All objects in OSS are stored in buckets.\n\nIt is recommended that the access policy on OSS buckets does not allow **anonymous** and/or **public access**.", - "Risk": "Allowing **anonymous** and/or **public access** grants permissions to anyone to access bucket content. Such access might not be desired if you are storing any sensitive data.\n\nPublic buckets can lead to **data breaches**, **unauthorized data access**, and **compliance violations**.", + "Description": "**Alibaba Cloud Object Storage Service (OSS)** buckets store objects that may contain sensitive data. It is recommended that the access policy on OSS buckets does not allow **anonymous** or **public access**, ensuring that only authorized identities can interact with bucket contents. The bucket ACL should be set to `private` to prevent unintended data exposure.", + "Risk": "Allowing **anonymous** or **public access** to OSS buckets grants permissions to anyone on the internet to read or modify bucket content. This can lead to **data breaches**, **unauthorized data exfiltration**, **data tampering**, and **compliance violations**, particularly when buckets contain sensitive or regulated information.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/31896.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-OSS/publicly-accessible-oss-bucket.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-OSS/publicly-accessible-oss-bucket.html" ], "Remediation": { "Code": { "CLI": "aliyun oss PutBucketAcl --bucket --acl private", "NativeIaC": "", - "Other": "", + "Other": "**Set Bucket ACL to Private:**\n1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Click on **Basic Setting** in the top middle of the console\n4. Under ACL section, click on **Configure**\n5. Click **Private** and click **Save**\n\n**For Bucket Policy:**\n1. Click **Bucket**, and then click the name of the target bucket\n2. Click the **Files** tab and click **Authorize**\n3. In the Authorize dialog, choose `Anonymous Accounts (*)` for Accounts and choose `None` for Authorized Operation\n4. Click **OK**", "Terraform": "resource \"alicloud_oss_bucket_public_access_block\" \"example\" {\n bucket = alicloud_oss_bucket.example.bucket\n block_public_access = true\n}" }, "Recommendation": { - "Text": "**Set Bucket ACL to Private:**\n1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Click on **Basic Setting** in the top middle of the console\n4. Under ACL section, click on **Configure**\n5. Click **Private** and click **Save**\n\n**For Bucket Policy:**\n1. Click **Bucket**, and then click the name of the target bucket\n2. Click the **Files** tab and click **Authorize**\n3. In the Authorize dialog, choose `Anonymous Accounts (*)` for Accounts and choose `None` for Authorized Operation\n4. Click **OK**", + "Text": "Set the OSS bucket ACL to private and configure bucket policies to deny anonymous or public access, ensuring only authorized identities can access stored objects.", "Url": "https://hub.prowler.com/check/oss_bucket_not_publicly_accessible" } }, diff --git a/prowler/providers/alibabacloud/services/oss/oss_bucket_secure_transport_enabled/oss_bucket_secure_transport_enabled.metadata.json b/prowler/providers/alibabacloud/services/oss/oss_bucket_secure_transport_enabled/oss_bucket_secure_transport_enabled.metadata.json index d5b0a59bf9..b112b84d43 100644 --- a/prowler/providers/alibabacloud/services/oss/oss_bucket_secure_transport_enabled/oss_bucket_secure_transport_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/oss/oss_bucket_secure_transport_enabled/oss_bucket_secure_transport_enabled.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "oss_bucket_secure_transport_enabled", - "CheckTitle": "Secure transfer required is set to Enabled", - "CheckType": [ - "Sensitive file tampering" - ], + "CheckTitle": "Secure transfer required is enabled for OSS buckets", + "CheckType": [], "ServiceName": "oss", "SubServiceName": "", - "ResourceIdTemplate": "acs:oss::account-id:bucket-name", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudOSSBucket", + "ResourceType": "ALIYUN::OSS::Bucket", "ResourceGroup": "storage", - "Description": "Enable **data encryption in transit**. The secure transfer enhances the security of OSS buckets by only allowing requests to the storage account via a secure connection.\n\nFor example, when calling REST APIs to access storage accounts, the connection must use **HTTPS**. Any requests using HTTP will be rejected.", - "Risk": "Without **secure transfer enforcement**, OSS buckets may accept HTTP requests, which are not encrypted in transit.\n\nThis exposes data to potential **interception** and **man-in-the-middle attacks**, compromising data confidentiality and integrity.", + "Description": "**Alibaba Cloud OSS** buckets should enforce **secure transfer** by requiring all requests to use HTTPS. A bucket policy that denies requests with `acs:SecureTransport` set to `false` ensures that data in transit is encrypted, rejecting any unencrypted HTTP connections to the storage endpoint.", + "Risk": "Without **secure transfer enforcement**, OSS buckets accept HTTP requests that transmit data in plaintext. This exposes stored data to potential **interception**, **man-in-the-middle attacks**, and **eavesdropping**, compromising data **confidentiality** and **integrity** during transit.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/85111.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-OSS/enable-secure-transfer.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-OSS/enable-secure-transfer.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun oss PutBucketPolicy --bucket --policy '{\"Version\":\"1\",\"Statement\":[{\"Effect\":\"Deny\",\"Principal\":[\"*\"],\"Action\":[\"oss:*\"],\"Resource\":[\"acs:oss:*:*:\",\"acs:oss:*:*:/*\"],\"Condition\":{\"Bool\":{\"acs:SecureTransport\":\"false\"}}}]}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Click on **Files** in the top middle of the console\n4. Click on **Authorize**\n5. Configure: `Whole Bucket`, `*`, `None` (Authorized Operation) and `http` (Conditions: Access Method) to deny HTTP access\n6. Click **Save**", "Terraform": "resource \"alicloud_oss_bucket\" \"example\" {\n bucket = \"example-bucket\"\n \n policy = jsonencode({\n \"Version\": \"1\",\n \"Statement\": [{\n \"Effect\": \"Deny\",\n \"Principal\": [\"*\"],\n \"Action\": [\"oss:*\"],\n \"Resource\": [\"acs:oss:*:*:example-bucket\", \"acs:oss:*:*:example-bucket/*\"],\n \"Condition\": {\n \"Bool\": {\n \"acs:SecureTransport\": \"false\"\n }\n }\n }]\n })\n}" }, "Recommendation": { - "Text": "1. Log on to the **OSS Console**\n2. In the bucket-list pane, click on a target OSS bucket\n3. Click on **Files** in the top middle of the console\n4. Click on **Authorize**\n5. Configure: `Whole Bucket`, `*`, `None` (Authorized Operation) and `http` (Conditions: Access Method) to deny HTTP access\n6. Click **Save**", + "Text": "Enforce secure transfer on OSS buckets by applying a bucket policy that denies all requests not using HTTPS, ensuring data in transit is always encrypted.", "Url": "https://hub.prowler.com/check/oss_bucket_secure_transport_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_no_root_access_key/ram_no_root_access_key.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_no_root_access_key/ram_no_root_access_key.metadata.json index be7ec536fd..233117c54c 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_no_root_access_key/ram_no_root_access_key.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_no_root_access_key/ram_no_root_access_key.metadata.json @@ -2,32 +2,29 @@ "Provider": "alibabacloud", "CheckID": "ram_no_root_access_key", "CheckTitle": "No root account access key exists", - "CheckType": [ - "Unusual logon", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:root", + "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "AlibabaCloudRAMAccessKey", + "ResourceType": "ALIYUN::RAM::User", "ResourceGroup": "IAM", - "Description": "Ensure no **root account access key** exists. Access keys provide programmatic access to a given Alibaba Cloud account.\n\nIt is recommended that all access keys associated with the root account be removed.", - "Risk": "The **root account** is the most privileged user in an Alibaba Cloud account. Access Keys provide programmatic access to a given Alibaba Cloud account.\n\nRemoving access keys associated with the root account limits vectors by which the account can be compromised and encourages the creation and use of **role-based accounts** that are least privileged.", + "Description": "**Alibaba Cloud RAM** access keys provide programmatic access to an account. The **root account** is the most privileged user and should not have access keys. All root access keys should be removed to limit compromise vectors and encourage **role-based accounts** following the principle of least privilege.", + "Risk": "The **root account** has unrestricted access to all resources and services. If its access keys are compromised, an attacker gains **full administrative control**, including ability to create, modify, or delete any resource. This poses critical risk to the **confidentiality**, **integrity**, and **availability** of all cloud resources and data.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/102600.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/remove-root-access-keys.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/remove-root-access-keys.html" ], "Remediation": { "Code": { "CLI": "aliyun ram DeleteAccessKey --UserAccessKeyId ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console** by using your Alibaba Cloud account (root account).\n2. Move the pointer over the account icon in the upper-right corner and click **AccessKey**.\n3. Click **Continue to manage AccessKey**.\n4. On the Security Management page, find the target access keys and click **Delete** to delete the target access keys permanently.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console** by using your Alibaba Cloud account (root account)\n2. Move the pointer over the account icon in the upper-right corner and click **AccessKey**\n3. Click **Continue to manage AccessKey**\n4. On the Security Management page, find the target access keys and click **Delete** to delete the target access keys permanently", + "Text": "Remove all access keys associated with the root account to reduce the attack surface and encourage the use of role-based accounts with least privilege.", "Url": "https://hub.prowler.com/check/ram_no_root_access_key" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_lowercase/ram_password_policy_lowercase.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_password_policy_lowercase/ram_password_policy_lowercase.metadata.json index f5189bc2c8..5cfd7d06ae 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_password_policy_lowercase/ram_password_policy_lowercase.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_lowercase/ram_password_policy_lowercase.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_password_policy_lowercase", - "CheckTitle": "RAM password policy requires at least one lowercase letter", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "RAM password policy has lowercase letter requirement", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:password-policy", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMPasswordPolicy", + "ResourceType": "ALIYUN::RAM::SecurityPreference", "ResourceGroup": "IAM", - "Description": "**RAM password policies** can be used to ensure password complexity.\n\nIt is recommended that the password policy require at least one **lowercase letter**.", - "Risk": "Enhancing complexity of a password policy increases account resiliency against **brute force logon attempts**.\n\nWeak passwords without character variety are more susceptible to dictionary attacks and automated password cracking tools.", + "Description": "**Alibaba Cloud RAM** password policies can be used to enforce password complexity requirements. It is recommended that the password policy require at least one **lowercase letter** to increase the character diversity of passwords. This enhances account resiliency against **brute force logon attempts** and dictionary attacks.", + "Risk": "Without requiring **lowercase letters** in the password policy, users may create passwords with limited character diversity. Weak passwords without sufficient character variety are more susceptible to **dictionary attacks** and automated password cracking tools, potentially compromising the **confidentiality** of user accounts and the resources they have access to.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116413.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/lowercase-letter-password-policy.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/lowercase-letter-password-policy.html" ], "Remediation": { "Code": { "CLI": "aliyun ram SetPasswordPolicy --RequireLowercaseCharacters true", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Settings**.\n3. In the Password section, click **Modify**.\n4. In the Charset section, select **Lower case**.\n5. Click **OK**.", "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n require_lowercase_characters = true\n}" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Charset section, select **Lower case**\n5. Click **OK**", + "Text": "Configure the RAM password policy to require at least one lowercase letter to improve password complexity.", "Url": "https://hub.prowler.com/check/ram_password_policy_lowercase" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_max_login_attempts/ram_password_policy_max_login_attempts.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_password_policy_max_login_attempts/ram_password_policy_max_login_attempts.metadata.json index f2228326e7..9e4f4298b3 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_password_policy_max_login_attempts/ram_password_policy_max_login_attempts.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_max_login_attempts/ram_password_policy_max_login_attempts.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_password_policy_max_login_attempts", - "CheckTitle": "RAM password policy temporarily blocks logon after 5 incorrect logon attempts within an hour", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "RAM password policy temporarily blocks logon after 5 incorrect attempts within an hour", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:password-policy", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMPasswordPolicy", + "ResourceType": "ALIYUN::RAM::SecurityPreference", "ResourceGroup": "IAM", - "Description": "**RAM password policies** can temporarily block logon after several incorrect logon attempts within an hour.\n\nIt is recommended that the password policy is set to temporarily block logon after **5 incorrect logon attempts** within an hour.", - "Risk": "Temporarily blocking logon for incorrect password input increases account resiliency against **brute force logon attempts**.\n\nThis control helps prevent automated password guessing attacks from succeeding.", + "Description": "**Alibaba Cloud RAM** password policies can temporarily block logon after several incorrect logon attempts within an hour. It is recommended that the password policy is set to temporarily block logon after **5 incorrect logon attempts** within an hour to protect accounts against automated **brute force logon attempts** and credential stuffing attacks.", + "Risk": "Without an account lockout policy, attackers can make unlimited **brute force logon attempts** against RAM user accounts without any throttling. This significantly increases the risk of password compromise, potentially leading to unauthorized access to cloud resources and a breach of **confidentiality** and **integrity** of the account's data and services.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116413.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/max-login-attempts-password-policy.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/max-login-attempts-password-policy.html" ], "Remediation": { "Code": { "CLI": "aliyun ram SetPasswordPolicy --MaxLoginAttemps 5", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Settings**.\n3. In the Password section, click **Modify**.\n4. In the `Max Attempts` field, check the box next to **Enable** and enter `5`.\n5. Click **OK**.", "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n max_login_attemps = 5\n}" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the `Max Attempts` field, check the box next to **Enable** and enter `5`\n5. Click **OK**", + "Text": "Configure the RAM password policy to temporarily block logon after 5 incorrect attempts within an hour.", "Url": "https://hub.prowler.com/check/ram_password_policy_max_login_attempts" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_max_password_age/ram_password_policy_max_password_age.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_password_policy_max_password_age/ram_password_policy_max_password_age.metadata.json index a4138c2362..b365325db0 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_password_policy_max_password_age/ram_password_policy_max_password_age.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_max_password_age/ram_password_policy_max_password_age.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_password_policy_max_password_age", - "CheckTitle": "RAM password policy expires passwords in 365 days or greater", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "RAM password policy expires passwords within 365 days or less", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:password-policy", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMPasswordPolicy", + "ResourceType": "ALIYUN::RAM::SecurityPreference", "ResourceGroup": "IAM", - "Description": "**RAM password policies** can require passwords to be expired after a given number of days.\n\nIt is recommended that the password policy expire passwords after **365 days** or greater.", - "Risk": "Too frequent password changes are more harmful than beneficial. They offer no containment benefits and enforce bad habits, since they encourage users to choose variants of older passwords.\n\nThe CIS now recommends an **annual password reset** as a balanced approach.", + "Description": "**Alibaba Cloud RAM** password policies can require passwords to expire after a given number of days. It is recommended to expire passwords after **365 days** or less for periodic credential rotation. The CIS benchmark recommends an **annual reset** as a balanced approach that avoids overly frequent changes while ensuring compromised credentials have a limited lifespan.", + "Risk": "Without a maximum password age, compromised passwords remain valid **indefinitely**, giving attackers persistent access. A reasonable maximum of **365 days** ensures compromised credentials are eventually invalidated, reducing the window for unauthorized access and protecting **confidentiality** and **integrity** of account data.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116413.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/require-password-expiration-policy.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/require-password-expiration-policy.html" ], "Remediation": { "Code": { "CLI": "aliyun ram SetPasswordPolicy --MaxPasswordAge 365", "NativeIaC": "", - "Other": "", - "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n max_password_age = 90\n}" + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Settings**.\n3. In the Password section, click **Modify**.\n4. Check the box under `Max Age`, enter `365` or a smaller number.\n5. Click **OK**.", + "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n max_password_age = 365\n}" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. Check the box under `Max Age`, enter `365` or a greater number up to `1095`\n5. Click **OK**", + "Text": "Configure the RAM password policy to expire passwords within 365 days or less.", "Url": "https://hub.prowler.com/check/ram_password_policy_max_password_age" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_minimum_length/ram_password_policy_minimum_length.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_password_policy_minimum_length/ram_password_policy_minimum_length.metadata.json index 4c0c45c406..e8c3ec01ab 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_password_policy_minimum_length/ram_password_policy_minimum_length.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_minimum_length/ram_password_policy_minimum_length.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_password_policy_minimum_length", - "CheckTitle": "RAM password policy requires minimum length of 14 or greater", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "RAM password policy requires a minimum length of 14 or greater", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:password-policy", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMPasswordPolicy", + "ResourceType": "ALIYUN::RAM::SecurityPreference", "ResourceGroup": "IAM", - "Description": "**RAM password policies** can be used to ensure password complexity.\n\nIt is recommended that the password policy require a minimum of **14 or greater characters** for any password.", - "Risk": "Enhancing complexity of a password policy increases account resiliency against **brute force logon attempts**.\n\nLonger passwords provide exponentially more security against automated password cracking.", + "Description": "**Alibaba Cloud RAM** password policies can be used to enforce password complexity requirements. It is recommended that the password policy require a minimum of **14 or greater characters** for any password. Longer passwords provide exponentially more security against automated password cracking, as the keyspace increases dramatically with each additional character.", + "Risk": "Short passwords significantly reduce the effort required for **brute force attacks**. Passwords shorter than **14 characters** can be cracked much faster, potentially compromising **confidentiality** of user accounts. This can lead to unauthorized access to cloud resources and sensitive data, affecting the **integrity** and **availability** of the environment.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116413.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/require-14-characters-password-policy.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/require-14-characters-password-policy.html" ], "Remediation": { "Code": { "CLI": "aliyun ram SetPasswordPolicy --MinimumPasswordLength 14", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Settings**.\n3. In the Password section, click **Modify**.\n4. In the Length section, enter `14` or a greater number.\n5. Click **OK**.", "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n minimum_password_length = 14\n}" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Length section, enter `14` or a greater number\n5. Click **OK**", + "Text": "Configure the RAM password policy to require a minimum password length of 14 characters or greater.", "Url": "https://hub.prowler.com/check/ram_password_policy_minimum_length" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number.metadata.json index 29aba59a21..9b107baa91 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_number/ram_password_policy_number.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_password_policy_number", - "CheckTitle": "RAM password policy require at least one number", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "RAM password policy requires at least one number", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:password-policy", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMPasswordPolicy", + "ResourceType": "ALIYUN::RAM::SecurityPreference", "ResourceGroup": "IAM", - "Description": "**RAM password policies** can be used to ensure password complexity.\n\nIt is recommended that the password policy require at least one **number**.", - "Risk": "Enhancing complexity of a password policy increases account resiliency against **brute force logon attempts**.\n\nWeak passwords without numeric characters are more susceptible to dictionary attacks.", + "Description": "**Alibaba Cloud RAM** password policies can be used to enforce password complexity requirements. It is recommended that the password policy require at least one **numeric character** to increase the character diversity of passwords. This enhances account resiliency against **brute force logon attempts** and dictionary attacks by expanding the keyspace.", + "Risk": "Without requiring **numeric characters** in the password policy, users may create passwords composed only of alphabetic characters. Such passwords are more susceptible to **dictionary attacks** and automated cracking tools, potentially compromising the **confidentiality** of user accounts and the cloud resources they protect.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116413.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/require-number-password-policy.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/require-number-password-policy.html" ], "Remediation": { "Code": { "CLI": "aliyun ram SetPasswordPolicy --RequireNumbers true", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Settings**.\n3. In the Password section, click **Modify**.\n4. In the Charset section, select **Number**.\n5. Click **OK**.", "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n require_numbers = true\n}" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Charset section, select **Number**\n5. Click **OK**", + "Text": "Configure the RAM password policy to require at least one numeric character to improve password complexity.", "Url": "https://hub.prowler.com/check/ram_password_policy_number" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_password_reuse_prevention/ram_password_policy_password_reuse_prevention.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_password_policy_password_reuse_prevention/ram_password_policy_password_reuse_prevention.metadata.json index 761807fb00..199900a958 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_password_policy_password_reuse_prevention/ram_password_policy_password_reuse_prevention.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_password_reuse_prevention/ram_password_policy_password_reuse_prevention.metadata.json @@ -2,32 +2,29 @@ "Provider": "alibabacloud", "CheckID": "ram_password_policy_password_reuse_prevention", "CheckTitle": "RAM password policy prevents password reuse", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:password-policy", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMPasswordPolicy", + "ResourceType": "ALIYUN::RAM::SecurityPreference", "ResourceGroup": "IAM", - "Description": "It is recommended that the **password policy** prevent the reuse of passwords.\n\nThis ensures users cannot cycle back to previously compromised passwords.", - "Risk": "Preventing **password reuse** increases account resiliency against brute force logon attempts.\n\nIf a password is compromised and later reused, attackers with knowledge of old credentials can regain access.", + "Description": "**Alibaba Cloud RAM** password policies can be configured to prevent the reuse of previously used passwords. It is recommended that the password policy prevent the reuse of passwords to ensure users cannot cycle back to previously compromised credentials. This increases account resiliency against **brute force logon attempts** and reduces the risk of credential reuse attacks.", + "Risk": "Without **password reuse prevention**, users may cycle back to previously compromised passwords. Attackers with knowledge of old credentials can regain access, threatening the **confidentiality** and **integrity** of cloud resources. This weakens the overall security posture of the Alibaba Cloud environment.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116413.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/prevent-password-reuse-password-policy.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/prevent-password-reuse-password-policy.html" ], "Remediation": { "Code": { "CLI": "aliyun ram SetPasswordPolicy --PasswordReusePrevention 5", "NativeIaC": "", - "Other": "", - "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n password_reuse_prevention = 24\n}" + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Settings**.\n3. In the Password section, click **Modify**.\n4. In the `Do Not repeat History` section field, enter `5`.\n5. Click **OK**.", + "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n password_reuse_prevention = 5\n}" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the `Do Not repeat History` section field, enter `5`\n5. Click **OK**", + "Text": "Configure the RAM password policy to prevent the reuse of at least the last 5 passwords.", "Url": "https://hub.prowler.com/check/ram_password_policy_password_reuse_prevention" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_symbol/ram_password_policy_symbol.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_password_policy_symbol/ram_password_policy_symbol.metadata.json index 7c369a89c2..46fc154d33 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_password_policy_symbol/ram_password_policy_symbol.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_symbol/ram_password_policy_symbol.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_password_policy_symbol", - "CheckTitle": "RAM password policy require at least one symbol", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "RAM password policy requires at least one symbol", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:password-policy", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMPasswordPolicy", + "ResourceType": "ALIYUN::RAM::SecurityPreference", "ResourceGroup": "IAM", - "Description": "**RAM password policies** can be used to ensure password complexity.\n\nIt is recommended that the password policy require at least one **symbol**.", - "Risk": "Enhancing complexity of a password policy increases account resiliency against **brute force logon attempts**.\n\nSpecial characters significantly increase the keyspace that attackers must search.", + "Description": "**Alibaba Cloud RAM** password policies can be used to enforce password complexity requirements. It is recommended that the password policy require at least one **special character (symbol)** to increase the character diversity of passwords. Special characters significantly increase the keyspace that attackers must search, enhancing account resiliency against **brute force logon attempts**.", + "Risk": "Without requiring **symbols** in the password policy, users may create passwords composed only of alphanumeric characters. Such passwords have a reduced keyspace and are more susceptible to **brute force attacks** and automated password cracking tools, potentially compromising the **confidentiality** of user accounts and the cloud resources they protect.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116413.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/require-symbol-password-policy.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/require-symbol-password-policy.html" ], "Remediation": { "Code": { "CLI": "aliyun ram SetPasswordPolicy --RequireSymbols true", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Settings**.\n3. In the Password section, click **Modify**.\n4. In the Charset section, select **Symbol**.\n5. Click **OK**.", "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n require_symbols = true\n}" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Charset section, select **Symbol**\n5. Click **OK**", + "Text": "Configure the RAM password policy to require at least one symbol to improve password complexity.", "Url": "https://hub.prowler.com/check/ram_password_policy_symbol" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_password_policy_uppercase/ram_password_policy_uppercase.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_password_policy_uppercase/ram_password_policy_uppercase.metadata.json index 28f115bd3e..41e5ec4845 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_password_policy_uppercase/ram_password_policy_uppercase.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_password_policy_uppercase/ram_password_policy_uppercase.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_password_policy_uppercase", - "CheckTitle": "RAM password policy requires at least one uppercase letter", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "RAM password policy has uppercase letter requirement", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:password-policy", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMPasswordPolicy", + "ResourceType": "ALIYUN::RAM::SecurityPreference", "ResourceGroup": "IAM", - "Description": "**RAM password policies** can be used to ensure password complexity.\n\nIt is recommended that the password policy require at least one **uppercase letter**.", - "Risk": "Enhancing complexity of a password policy increases account resiliency against **brute force logon attempts**.\n\nWeak passwords without case variety are more susceptible to dictionary attacks.", + "Description": "**Alibaba Cloud RAM** password policies can be used to enforce password complexity requirements. It is recommended that the password policy require at least one **uppercase letter** to increase the character diversity of passwords. This enhances account resiliency against **brute force logon attempts** and dictionary attacks by requiring mixed-case passwords.", + "Risk": "Without requiring **uppercase letters** in the password policy, users may create passwords with limited case diversity. Weak passwords without case variety are more susceptible to **dictionary attacks** and automated password cracking tools, potentially compromising the **confidentiality** of user accounts and the resources they have access to.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116413.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/uppercase-letter-password-policy.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/uppercase-letter-password-policy.html" ], "Remediation": { "Code": { "CLI": "aliyun ram SetPasswordPolicy --RequireUppercaseCharacters true", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Settings**.\n3. In the Password section, click **Modify**.\n4. In the Charset section, select **Upper case**.\n5. Click **OK**.", "Terraform": "resource \"alicloud_ram_password_policy\" \"example\" {\n require_uppercase_characters = true\n}" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Settings**\n3. In the Password section, click **Modify**\n4. In the Charset section, select **Upper case**\n5. Click **OK**", + "Text": "Configure the RAM password policy to require at least one uppercase letter to improve password complexity.", "Url": "https://hub.prowler.com/check/ram_password_policy_uppercase" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_policy_attached_only_to_group_or_roles/ram_policy_attached_only_to_group_or_roles.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_policy_attached_only_to_group_or_roles/ram_policy_attached_only_to_group_or_roles.metadata.json index 0dfa1782bd..20afdf2dca 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_policy_attached_only_to_group_or_roles/ram_policy_attached_only_to_group_or_roles.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_policy_attached_only_to_group_or_roles/ram_policy_attached_only_to_group_or_roles.metadata.json @@ -2,32 +2,29 @@ "Provider": "alibabacloud", "CheckID": "ram_policy_attached_only_to_group_or_roles", "CheckTitle": "RAM policies are attached only to groups or roles", - "CheckType": [ - "Abnormal account", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:user/{user-name}", + "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "AlibabaCloudRAMUser", + "ResourceType": "ALIYUN::RAM::ManagedPolicy", "ResourceGroup": "IAM", - "Description": "By default, **RAM users**, groups, and roles have no access to Alibaba Cloud resources. RAM policies are the means by which privileges are granted to users, groups, or roles.\n\nIt is recommended that RAM policies be applied directly to **groups and roles** but not users.", - "Risk": "Assigning privileges at the **group or role level** reduces the complexity of access management as the number of users grows.\n\nReducing access management complexity may in turn reduce opportunity for a principal to inadvertently receive or retain **excessive privileges**.", + "Description": "**Alibaba Cloud RAM** users, groups, and roles have no access by default. RAM policies grant privileges to these principals. It is recommended to apply policies to **groups and roles** rather than individual users, simplifying access management and reducing unintended permissions as the number of users grows.", + "Risk": "Assigning privileges directly to users instead of **groups or roles** increases access management complexity. As users grow, this can lead to principals receiving **excessive privileges**, threatening **confidentiality** and **integrity** of cloud resources. It also makes auditing and compliance reviews significantly harder.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116820.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/receive-permissions-via-ram-groups-only.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/receive-permissions-via-ram-groups-only.html" ], "Remediation": { "Code": { "CLI": "aliyun ram DetachPolicyFromUser --PolicyName --PolicyType --UserName ", "NativeIaC": "", - "Other": "", + "Other": "1. Create **RAM user groups** and assign policies to those groups.\n2. Add users to the appropriate groups.\n3. Detach any policies directly attached to users using the RAM Console or CLI.", "Terraform": "" }, "Recommendation": { - "Text": "1. Create **RAM user groups** and assign policies to those groups\n2. Add users to the appropriate groups\n3. Detach any policies directly attached to users using the RAM Console or CLI", + "Text": "Detach policies from individual RAM users and attach them to groups or roles instead to simplify access management.", "Url": "https://hub.prowler.com/check/ram_policy_attached_only_to_group_or_roles" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_policy_no_administrative_privileges/ram_policy_no_administrative_privileges.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_policy_no_administrative_privileges/ram_policy_no_administrative_privileges.metadata.json index 3d2e085fc9..739d97a51b 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_policy_no_administrative_privileges/ram_policy_no_administrative_privileges.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_policy_no_administrative_privileges/ram_policy_no_administrative_privileges.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_policy_no_administrative_privileges", - "CheckTitle": "RAM policies that allow full \"*:*\" administrative privileges are not created", - "CheckType": [ - "Abnormal account", - "Cloud threat detection" - ], + "CheckTitle": "RAM policies do not allow full administrative privileges", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:policy/{policy-name}", + "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "AlibabaCloudRAMPolicy", + "ResourceType": "ALIYUN::RAM::ManagedPolicy", "ResourceGroup": "IAM", - "Description": "**RAM policies** represent permissions that can be granted to users, groups, or roles. It is recommended to grant **least privilege**—that is, granting only the permissions required to perform tasks.\n\nDetermine what users need to do and then create policies with permissions that only fit those tasks, instead of allowing full administrative privileges.", - "Risk": "It is more secure to start with a minimum set of permissions and grant additional permissions as necessary. Providing **full administrative privileges** exposes your resources to potentially unwanted actions.\n\nRAM policies with `\"Effect\": \"Allow\"`, `\"Action\": \"*\"`, and `\"Resource\": \"*\"` should be prohibited.", + "Description": "**Alibaba Cloud RAM** policies grant permissions to users, groups, or roles. Follow the principle of **least privilege** by granting only required permissions. Policies with `\"Effect\": \"Allow\"`, `\"Action\": \"*\"`, and `\"Resource\": \"*\"` should be avoided as they grant full administrative access to all resources.", + "Risk": "RAM policies granting **full administrative privileges** (`*:*`) expose all cloud resources to potentially unwanted actions. If such a policy is attached to a compromised user, group, or role, an attacker gains unrestricted access to create, modify, or delete any resource, severely impacting the **confidentiality**, **integrity**, and **availability** of the entire Alibaba Cloud environment.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/93733.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/policies-with-full-administrative-privileges.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/policies-with-full-administrative-privileges.html" ], "Remediation": { "Code": { "CLI": "aliyun ram DetachPolicyFromUser --PolicyName --PolicyType Custom --UserName ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Permissions** > **Policies**.\n3. From the Policy Type drop-down list, select **Custom Policy**.\n4. In the Policy Name column, click the name of the target policy.\n5. In the Policy Document section, edit the policy to remove the statement with full administrative privileges, or remove the policy from any RAM users, user groups, or roles that have this policy attached.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Permissions** > **Policies**\n3. From the Policy Type drop-down list, select **Custom Policy**\n4. In the Policy Name column, click the name of the target policy\n5. In the Policy Document section, edit the policy to remove the statement with full administrative privileges, or remove the policy from any RAM users, user groups, or roles that have this policy attached", + "Text": "Remove or modify RAM policies that grant full administrative privileges and replace them with least-privilege policies.", "Url": "https://hub.prowler.com/check/ram_policy_no_administrative_privileges" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_rotate_access_key_90_days/ram_rotate_access_key_90_days.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_rotate_access_key_90_days/ram_rotate_access_key_90_days.metadata.json index c5f4bf24ce..da8d81b20f 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_rotate_access_key_90_days/ram_rotate_access_key_90_days.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_rotate_access_key_90_days/ram_rotate_access_key_90_days.metadata.json @@ -2,32 +2,29 @@ "Provider": "alibabacloud", "CheckID": "ram_rotate_access_key_90_days", "CheckTitle": "Access keys are rotated every 90 days or less", - "CheckType": [ - "Unusual logon", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:user/{user-name}/accesskey/{access-key-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMAccessKey", + "ResourceType": "ALIYUN::RAM::User", "ResourceGroup": "IAM", - "Description": "An **access key** consists of an access key ID and a secret, which are used to sign programmatic requests that you make to Alibaba Cloud.\n\nRAM users need their own access keys to make programmatic calls from SDKs, CLIs, or direct API calls. It is recommended that all access keys be **regularly rotated**.", - "Risk": "Access keys might be compromised by leaving them in code, configuration files, on-premise and cloud storages, and then stolen by attackers.\n\n**Rotating access keys** reduces the window of opportunity for a compromised access key to be used.", + "Description": "**Alibaba Cloud RAM** access keys consist of an access key ID and a secret, which are used to sign programmatic requests. RAM users need their own access keys to make programmatic calls from SDKs, CLIs, or direct API calls. It is recommended that all access keys be **regularly rotated** every 90 days or less to reduce the window of opportunity for compromised keys to be used.", + "Risk": "Access keys might be compromised by being left in code, configuration files, or cloud storage and then stolen by attackers. Without regular **access key rotation**, a compromised key can remain valid indefinitely, allowing persistent unauthorized access. This threatens the **confidentiality**, **integrity**, and **availability** of all resources accessible via those credentials.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116401.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/access-keys-rotation.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/access-keys-rotation.html" ], "Remediation": { "Code": { "CLI": "aliyun ram CreateAccessKey --UserName && aliyun ram UpdateAccessKey --UserAccessKeyId --Status Inactive --UserName && aliyun ram DeleteAccessKey --UserAccessKeyId --UserName ", "NativeIaC": "", - "Other": "", + "Other": "1. Create a new **AccessKey pair** for rotation.\n2. Update all applications and systems to use the new AccessKey pair.\n3. **Disable** the original AccessKey pair.\n4. Confirm that your applications and systems are working.\n5. **Delete** the original AccessKey pair.", "Terraform": "" }, "Recommendation": { - "Text": "1. Create a new **AccessKey pair** for rotation\n2. Update all applications and systems to use the new AccessKey pair\n3. **Disable** the original AccessKey pair\n4. Confirm that your applications and systems are working\n5. **Delete** the original AccessKey pair", + "Text": "Rotate all RAM user access keys every 90 days or less to limit the impact of compromised credentials.", "Url": "https://hub.prowler.com/check/ram_rotate_access_key_90_days" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_user_console_access_unused/ram_user_console_access_unused.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_user_console_access_unused/ram_user_console_access_unused.metadata.json index 1f6b644a46..6cb920bc55 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_user_console_access_unused/ram_user_console_access_unused.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_user_console_access_unused/ram_user_console_access_unused.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_user_console_access_unused", - "CheckTitle": "Users not logged on for 90 days or longer are disabled for console logon", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "RAM user not logged on for 90 days or longer has console logon disabled", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:user/{user-name}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRAMUser", + "ResourceType": "ALIYUN::RAM::User", "ResourceGroup": "IAM", - "Description": "Alibaba Cloud **RAM users** can log on to the Alibaba Cloud console by using their username and password.\n\nIf a user has not logged on for **90 days or longer**, it is recommended to disable the console access of the user.", - "Risk": "Disabling users from having unnecessary logon privileges will reduce the opportunity that an **abandoned user** or a user with **compromised password** to be exploited.\n\nInactive accounts are common targets for attackers attempting account takeover.", + "Description": "**Alibaba Cloud RAM** users can log on to the console by using their username and password. If a user has not logged on for **90 days or longer**, it is recommended to disable the console access of the user. Disabling unused console access reduces the attack surface by removing unnecessary logon capabilities from potentially abandoned or dormant accounts.", + "Risk": "Inactive accounts with console access are common targets for **account takeover**. An abandoned account or one with a **compromised password** unused for over 90 days may go unmonitored, allowing undetected unauthorized access. This risks the **confidentiality** and **integrity** of cloud resources accessible through the account.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/116820.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/inactive-ram-user.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/inactive-ram-user.html" ], "Remediation": { "Code": { "CLI": "aliyun ram DeleteLoginProfile --UserName ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. Choose **Identities** > **Users**.\n3. In the User Logon Name/Display Name column, click the username of the target RAM user.\n4. In the Console Logon Management section, click **Modify Logon Settings**.\n5. In the Console Password Logon section, select **Disabled**.\n6. Click **OK**.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. Choose **Identities** > **Users**\n3. In the User Logon Name/Display Name column, click the username of the target RAM user\n4. In the Console Logon Management section, click **Modify Logon Settings**\n5. In the Console Password Logon section, select **Disabled**\n6. Click **OK**", + "Text": "Disable console access for RAM users that have not logged on for 90 days or longer to reduce the attack surface.", "Url": "https://hub.prowler.com/check/ram_user_console_access_unused" } }, diff --git a/prowler/providers/alibabacloud/services/ram/ram_user_mfa_enabled_console_access/ram_user_mfa_enabled_console_access.metadata.json b/prowler/providers/alibabacloud/services/ram/ram_user_mfa_enabled_console_access/ram_user_mfa_enabled_console_access.metadata.json index 11d8177294..13b738620d 100644 --- a/prowler/providers/alibabacloud/services/ram/ram_user_mfa_enabled_console_access/ram_user_mfa_enabled_console_access.metadata.json +++ b/prowler/providers/alibabacloud/services/ram/ram_user_mfa_enabled_console_access/ram_user_mfa_enabled_console_access.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "ram_user_mfa_enabled_console_access", - "CheckTitle": "Multi-factor authentication is enabled for all RAM users that have a console password", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "Multi-factor authentication is enabled for all RAM users with console access", + "CheckType": [], "ServiceName": "ram", "SubServiceName": "", - "ResourceIdTemplate": "acs:ram::account-id:user/{user-name}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudRAMUser", + "ResourceType": "ALIYUN::RAM::User", "ResourceGroup": "IAM", - "Description": "**Multi-Factor Authentication (MFA)** adds an extra layer of protection on top of a username and password.\n\nWith MFA enabled, when a user logs on to Alibaba Cloud, they will be prompted for their username and password followed by an authentication code from their virtual MFA device. It is recommended that MFA be enabled for all users that have a console password.", - "Risk": "**MFA** requires users to verify their identities by entering two authentication factors. When MFA is enabled, an attacker faces at least two different authentication mechanisms.\n\nThe additional security makes it significantly harder for an attacker to gain access even if passwords are compromised.", + "Description": "**Alibaba Cloud RAM** supports **MFA**, adding protection on top of username and password. With MFA enabled, console logon requires an authentication code from a virtual MFA device after entering credentials. MFA should be enabled for all RAM users with console passwords to strengthen account security.", + "Risk": "Without **MFA**, RAM accounts rely solely on passwords. If compromised through phishing or credential stuffing, an attacker gains full account access. MFA requires an additional factor, making unauthorized access significantly harder even with compromised credentials, protecting **confidentiality**, **integrity**, and **availability** of cloud resources.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/119555.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/ram-user-multi-factor-authentication-enabled.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RAM/ram-user-multi-factor-authentication-enabled.html" ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RAM Console**.\n2. For each user with console access, go to the user's details.\n3. In the **Console Logon Management** section, click **Modify Logon Settings**.\n4. For `Enable MFA`, select **Required**.\n5. Click **OK** to save the settings.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RAM Console**\n2. For each user with console access, go to the user's details\n3. In the **Console Logon Management** section, click **Modify Logon Settings**\n4. For `Enable MFA`, select **Required**\n5. Click **OK** to save the settings", + "Text": "Enable MFA for all RAM users with console access to add an extra layer of authentication security.", "Url": "https://hub.prowler.com/check/ram_user_mfa_enabled_console_access" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_no_public_access_whitelist/rds_instance_no_public_access_whitelist.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_no_public_access_whitelist/rds_instance_no_public_access_whitelist.metadata.json index ac073851a7..02305aa74b 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_no_public_access_whitelist/rds_instance_no_public_access_whitelist.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_no_public_access_whitelist/rds_instance_no_public_access_whitelist.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_no_public_access_whitelist", - "CheckTitle": "RDS Instances are not open to the world", - "CheckType": [ - "Intrusion into applications", - "Suspicious network connection" - ], + "CheckTitle": "RDS instance does not allow public access in the IP whitelist", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "Database Server should accept connections only from trusted **Network(s)/IP(s)** and restrict access from the world.\n\nTo minimize attack surface on a Database server Instance, only trusted/known and required IPs should be whitelisted. Authorized network should not have IPs/networks configured to `0.0.0.0` or `/0` which would allow access from anywhere in the world.", - "Risk": "Allowing **public access** (`0.0.0.0/0`) to the database significantly increases the risk of **brute-force attacks**, **unauthorized access**, and **data exfiltration**.\n\nDatabases exposed to the internet are prime targets for attackers.", + "Description": "**Alibaba Cloud RDS instances** should only accept connections from trusted networks and IP addresses. This check verifies that the IP whitelist does not contain entries such as `0.0.0.0/0` or `0.0.0.0` that would allow access from anywhere on the internet. Only specific, trusted IP addresses should be whitelisted to minimize the attack surface of the database server.", + "Risk": "Allowing **public access** (`0.0.0.0/0`) to the database significantly increases the risk of **brute-force attacks**, **unauthorized access**, and **data exfiltration**. Databases exposed to the internet are prime targets for attackers, and a successful breach can compromise **confidentiality**, **integrity**, and **availability** of all stored data.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/26198.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/disable-network-public-access.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/disable-network-public-access.html" ], "Remediation": { "Code": { - "CLI": "aliyun rds ModifySecurityIps --DBInstanceId --SecurityIps ", + "CLI": "aliyun rds ModifySecurityIps --DBInstanceId --SecurityIps ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the target RDS instance.\n3. Go to **Data Security** > **Whitelist Settings** tab.\n4. Remove any `0.0.0.0` or `0.0.0.0/0` entries.\n5. Add only the specific IP addresses that need to access the instance.\n6. Click **OK** to save changes.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. Go to **Data Security** > **Whitelist Settings** tab\n3. Remove any `0.0.0.0` or `/0` entries\n4. Only add the IP addresses that need to access the instance", + "Text": "Restrict the RDS IP whitelist to only trusted IP addresses. Remove any entries that allow unrestricted access such as `0.0.0.0/0`.", "Url": "https://hub.prowler.com/check/rds_instance_no_public_access_whitelist" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_connections_enabled/rds_instance_postgresql_log_connections_enabled.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_connections_enabled/rds_instance_postgresql_log_connections_enabled.metadata.json index ee59a7cd50..e81fd5a60f 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_connections_enabled/rds_instance_postgresql_log_connections_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_connections_enabled/rds_instance_postgresql_log_connections_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_postgresql_log_connections_enabled", - "CheckTitle": "Parameter log_connections is set to ON for PostgreSQL Database", - "CheckType": [ - "Intrusion into applications", - "Unusual logon" - ], + "CheckTitle": "RDS PostgreSQL instance has log_connections parameter enabled", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "Enable `log_connections` on **PostgreSQL Servers**. Enabling `log_connections` helps PostgreSQL Database log attempted connections to the server, as well as successful completion of client authentication.\n\nLog data can be used to identify, troubleshoot, and repair configuration errors and suboptimal performance.", - "Risk": "Without **connection logging**, unauthorized access attempts might go unnoticed, and troubleshooting connection issues becomes more difficult.\n\nThis data is essential for **security monitoring** and **incident investigation**.", + "Description": "**Alibaba Cloud RDS PostgreSQL instances** should have the `log_connections` parameter set to `on`. Enabling this parameter logs each attempted connection to the server, including successful client authentication. This log data is essential for identifying, troubleshooting, and repairing configuration errors, detecting unauthorized access attempts, and supporting **security auditing**.", + "Risk": "Without **connection logging** enabled, unauthorized access attempts to the database may go unnoticed, making it difficult to detect **brute-force attacks** or **credential compromise**. This gap in visibility impacts the ability to perform **security monitoring**, **incident investigation**, and **forensic analysis**, reducing overall **confidentiality** assurance.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/96751.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-log-connections-for-postgresql.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-log-connections-for-postgresql.html" ], "Remediation": { "Code": { "CLI": "aliyun rds ModifyParameter --DBInstanceId --Parameters \"{\\\"log_connections\\\":\\\"on\\\"}\"", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the region and target PostgreSQL instance.\n3. In the left-side navigation pane, select **Parameters**.\n4. Find the `log_connections` parameter and set it to `on`.\n5. Click **Apply Changes**.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. Select the region and target instance\n3. In the left-side navigation pane, select **Parameters**\n4. Find the `log_connections` parameter and set it to `on`\n5. Click **Apply Changes**", + "Text": "Enable the `log_connections` parameter on all PostgreSQL RDS instances to log connection attempts for security monitoring and troubleshooting.", "Url": "https://hub.prowler.com/check/rds_instance_postgresql_log_connections_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_disconnections_enabled/rds_instance_postgresql_log_disconnections_enabled.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_disconnections_enabled/rds_instance_postgresql_log_disconnections_enabled.metadata.json index 2255790916..7c358b77df 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_disconnections_enabled/rds_instance_postgresql_log_disconnections_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_disconnections_enabled/rds_instance_postgresql_log_disconnections_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_postgresql_log_disconnections_enabled", - "CheckTitle": "Server parameter log_disconnections is set to ON for PostgreSQL Database Server", - "CheckType": [ - "Intrusion into applications", - "Unusual logon" - ], + "CheckTitle": "RDS PostgreSQL instance has log_disconnections parameter enabled", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "Enable `log_disconnections` on **PostgreSQL Servers**. Enabling `log_disconnections` helps PostgreSQL Database log session terminations of the server, as well as duration of the session.\n\nLog data can be used to identify, troubleshoot, and repair configuration errors and suboptimal performance.", - "Risk": "Without **disconnection logging**, it's harder to track session durations and identify abnormal disconnection patterns that might indicate **attacks** or **stability issues**.", + "Description": "**Alibaba Cloud RDS PostgreSQL instances** should have the `log_disconnections` parameter set to `on`. Enabling this parameter logs session terminations and the duration of each session. This data is valuable for identifying abnormal disconnection patterns, troubleshooting performance issues, and supporting **security auditing** and **incident investigation**.", + "Risk": "Without **disconnection logging**, it is harder to track session durations and identify abnormal disconnection patterns that might indicate **attacks**, **session hijacking**, or **stability issues**. This reduces visibility into database activity, impacting **security monitoring** and the ability to perform effective **forensic analysis**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/96751.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-log-disconnections-for-postgresql.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-log-disconnections-for-postgresql.html" ], "Remediation": { "Code": { "CLI": "aliyun rds ModifyParameter --DBInstanceId --Parameters \"{\\\"log_disconnections\\\":\\\"on\\\"}\"", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the region and target PostgreSQL instance.\n3. In the left-side navigation pane, select **Parameters**.\n4. Find the `log_disconnections` parameter and set it to `on`.\n5. Click **Apply Changes**.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. Select the region and target instance\n3. In the left-side navigation pane, select **Parameters**\n4. Find the `log_disconnections` parameter and set it to `on`\n5. Click **Apply Changes**", + "Text": "Enable the `log_disconnections` parameter on all PostgreSQL RDS instances to log session terminations for security monitoring and troubleshooting.", "Url": "https://hub.prowler.com/check/rds_instance_postgresql_log_disconnections_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_duration_enabled/rds_instance_postgresql_log_duration_enabled.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_duration_enabled/rds_instance_postgresql_log_duration_enabled.metadata.json index dc5a2d9d3a..a0c04e5228 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_duration_enabled/rds_instance_postgresql_log_duration_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_postgresql_log_duration_enabled/rds_instance_postgresql_log_duration_enabled.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_postgresql_log_duration_enabled", - "CheckTitle": "Server parameter log_duration is set to ON for PostgreSQL Database Server", - "CheckType": [ - "Intrusion into applications" - ], + "CheckTitle": "RDS PostgreSQL instance has log_duration parameter enabled", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "Enable `log_duration` on **PostgreSQL Servers**. Enabling `log_duration` helps PostgreSQL Database log the duration of each completed SQL statement which in turn generates query and error logs.\n\nQuery and error logs can be used to identify, troubleshoot, and repair configuration errors and sub-optimal performance.", - "Risk": "Without **duration logging**, it's difficult to identify **slow queries**, **performance bottlenecks**, and potential **DoS attempts**.\n\nThis information is critical for database performance tuning and security monitoring.", + "Description": "**Alibaba Cloud RDS PostgreSQL instances** should have the `log_duration` parameter set to `on`. Enabling this parameter logs the duration of each completed SQL statement, generating query and error logs that can be used to identify **slow queries**, troubleshoot performance issues, and detect potential **denial-of-service** patterns targeting the database.", + "Risk": "Without **duration logging**, it is difficult to identify **slow queries**, **performance bottlenecks**, and potential **DoS attempts** against the database. This lack of visibility impacts the ability to optimize database performance and detect **malicious activity** such as resource exhaustion attacks, reducing overall **availability** assurance.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/96751.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-log-duration-for-postgresql.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-log-duration-for-postgresql.html" ], "Remediation": { "Code": { "CLI": "aliyun rds ModifyParameter --DBInstanceId --Parameters \"{\\\"log_duration\\\":\\\"on\\\"}\"", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the region and target PostgreSQL instance.\n3. In the left-side navigation pane, select **Parameters**.\n4. Find the `log_duration` parameter and set it to `on`.\n5. Click **Apply Changes**.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. Select the region and target instance\n3. In the left-side navigation pane, select **Parameters**\n4. Find the `log_duration` parameter and set it to `on`\n5. Click **Apply Changes**", + "Text": "Enable the `log_duration` parameter on all PostgreSQL RDS instances to log SQL statement durations for performance monitoring and security analysis.", "Url": "https://hub.prowler.com/check/rds_instance_postgresql_log_duration_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_sql_audit_enabled/rds_instance_sql_audit_enabled.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_sql_audit_enabled/rds_instance_sql_audit_enabled.metadata.json index 2506245412..ee0e937667 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_sql_audit_enabled/rds_instance_sql_audit_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_sql_audit_enabled/rds_instance_sql_audit_enabled.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_sql_audit_enabled", - "CheckTitle": "Auditing is set to On for applicable database instances", - "CheckType": [ - "Intrusion into applications" - ], + "CheckTitle": "RDS instance has SQL auditing enabled", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "Enable **SQL auditing** on all RDS instances (except SQL Server 2012/2016/2017 and MariaDB TX). Auditing tracks database events and writes them to an audit log.\n\nIt helps to maintain **regulatory compliance**, understand database activity, and gain insight into discrepancies and anomalies that could indicate business concerns or suspected security violations.", - "Risk": "Without **SQL auditing**, it's difficult to detect **unauthorized access**, **data breaches**, or **malicious activity** within the database.\n\nIt also hinders **forensic investigations** and compliance reporting.", + "Description": "**Alibaba Cloud RDS instances** should have **SQL auditing** (SQL Explorer) enabled to track database events in an audit log. This helps maintain **regulatory compliance**, understand database activity, and detect anomalies indicating security violations. Applies to all RDS engines except SQL Server 2012/2016/2017 and MariaDB TX.", + "Risk": "Without **SQL auditing**, it is difficult to detect **unauthorized access**, **data breaches**, or **malicious activity** within the database. The absence of audit logs hinders **forensic investigations**, compliance reporting, and the ability to identify **data exfiltration** or **privilege escalation** attempts, impacting **confidentiality** and **integrity**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/96123.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-audit-logs.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-audit-logs.html" ], "Remediation": { "Code": { "CLI": "aliyun rds ModifySQLCollectorPolicy --DBInstanceId --SQLCollectorStatus Enable --StoragePeriod ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the target RDS instance.\n3. In the left-side navigation pane, select **SQL Explorer**.\n4. Click **Activate Now**.\n5. Specify the SQL log storage duration.\n6. Click **Activate**.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. In the left-side navigation pane, select **SQL Explorer**\n3. Click **Activate Now**\n4. Specify the SQL log storage duration\n5. Click **Activate**", + "Text": "Enable **SQL auditing** (SQL Explorer) on all applicable RDS instances to track database events and maintain audit logs for security monitoring and compliance.", "Url": "https://hub.prowler.com/check/rds_instance_sql_audit_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_sql_audit_retention/rds_instance_sql_audit_retention.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_sql_audit_retention/rds_instance_sql_audit_retention.metadata.json index 17300c26e5..2402bf7146 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_sql_audit_retention/rds_instance_sql_audit_retention.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_sql_audit_retention/rds_instance_sql_audit_retention.metadata.json @@ -1,32 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_sql_audit_retention", - "CheckTitle": "Auditing Retention is greater than the configured period", - "CheckType": [ - "Intrusion into applications" - ], + "CheckTitle": "RDS instance SQL audit retention period meets the configured minimum", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "Database **SQL Audit Retention** should be configured to be greater than or equal to the configured period (default: **6 months / 180 days**).\n\nAudit Logs can be used to check for anomalies and give insight into suspected breaches or misuse of information and access.", - "Risk": "**Short retention periods** for audit logs can result in the loss of critical forensic data needed for **incident investigation** and **compliance auditing**.\n\nMany regulations require minimum retention periods for audit data.", + "Description": "**Alibaba Cloud RDS instances** with SQL auditing enabled should have a retention period configured to be greater than or equal to the required minimum (default: **6 months / 180 days**). Audit logs are essential for checking anomalies, understanding database activity, and gaining insight into suspected breaches or misuse of information and access.", + "Risk": "**Short retention periods** for audit logs can result in the loss of critical forensic data needed for **incident investigation**, **compliance auditing**, and **regulatory reporting**. Many regulations and security frameworks require minimum retention periods for audit data, and failing to meet them can result in **non-compliance** penalties.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/96123.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/configure-log-retention-period.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/configure-log-retention-period.html" ], "Remediation": { "Code": { "CLI": "aliyun rds ModifySQLCollectorPolicy --DBInstanceId --SQLCollectorStatus Enable --StoragePeriod 180", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the target RDS instance.\n3. In the left-side navigation pane, select **SQL Explorer**.\n4. Click **Service Setting**.\n5. Enable `Activate SQL Explorer` if not already active.\n6. Set the storage duration to `6 months` or longer.\n7. Click **OK** to save changes.", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. Select **SQL Explorer**\n3. Click **Service Setting**\n4. Enable `Activate SQL Explorer`\n5. Set the storage duration to `6 months` or longer", + "Text": "Configure the SQL audit retention period to at least **180 days** (6 months) on all RDS instances to ensure adequate audit log availability for compliance and forensic purposes.", "Url": "https://hub.prowler.com/check/rds_instance_sql_audit_retention" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_ssl_enabled/rds_instance_ssl_enabled.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_ssl_enabled/rds_instance_ssl_enabled.metadata.json index c00ac879ec..aa1ff3224f 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_ssl_enabled/rds_instance_ssl_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_ssl_enabled/rds_instance_ssl_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_ssl_enabled", - "CheckTitle": "RDS instance requires all incoming connections to use SSL", - "CheckType": [ - "Sensitive file tampering", - "Intrusion into applications" - ], + "CheckTitle": "RDS instance has SSL encryption enabled", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "It is recommended to enforce all incoming connections to SQL database instances to use **SSL**.\n\nSQL database connections if successfully intercepted (MITM) can reveal sensitive data like credentials, database queries, and query outputs. For security, it is recommended to always use SSL encryption when connecting to your instance.", - "Risk": "If **SSL is not enabled**, data in transit (including credentials and query results) can be intercepted by attackers performing **Man-in-the-Middle (MITM) attacks**.\n\nThis compromises data confidentiality and integrity.", + "Description": "**Alibaba Cloud RDS instances** should enforce **SSL encryption** for all incoming connections. SSL protects data in transit between the application and the database, preventing interception of sensitive data such as credentials, database queries, and query outputs. This check verifies that SSL encryption is enabled on the RDS instance.", + "Risk": "If **SSL is not enabled**, data in transit including credentials and query results can be intercepted by attackers performing **Man-in-the-Middle (MITM) attacks**. This compromises data **confidentiality** and **integrity**, potentially leading to **credential theft**, **data exfiltration**, and unauthorized manipulation of database communications.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/32474.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-encryption-in-transit.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-encryption-in-transit.html" ], "Remediation": { "Code": { "CLI": "aliyun rds ModifyDBInstanceSSL --DBInstanceId --SSLEnabled 1", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the region and target instance.\n3. In the left-side navigation pane, click **Data Security**.\n4. Click the **SSL Encryption** tab.\n5. Click the switch next to **Disabled** to enable SSL encryption.\n6. Download the SSL CA certificate for client configuration.", "Terraform": "resource \"alicloud_db_instance\" \"example\" {\n engine = \"MySQL\"\n engine_version = \"8.0\"\n instance_type = \"rds.mysql.s1.small\"\n instance_storage = 20\n ssl_action = \"Open\"\n}" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. Select the region and target instance\n3. In the left-side navigation pane, click **Data Security**\n4. Click the **SSL Encryption** tab\n5. Click the switch next to **Disabled** in the SSL Encryption parameter to enable it", + "Text": "Enable **SSL encryption** on all RDS instances to protect data in transit and prevent Man-in-the-Middle attacks.", "Url": "https://hub.prowler.com/check/rds_instance_ssl_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_tde_enabled/rds_instance_tde_enabled.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_tde_enabled/rds_instance_tde_enabled.metadata.json index da57605bfe..0b033bdbb4 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_tde_enabled/rds_instance_tde_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_tde_enabled/rds_instance_tde_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_tde_enabled", - "CheckTitle": "TDE is set to Enabled on for applicable database instance", - "CheckType": [ - "Sensitive file tampering", - "Intrusion into applications" - ], + "CheckTitle": "RDS instance has Transparent Data Encryption enabled", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "Enable **Transparent Data Encryption (TDE)** on every RDS instance. RDS Database TDE helps protect against the threat of malicious activity by performing real-time encryption and decryption of the database, associated backups, and log files at rest.\n\nNo changes to the application are required.", - "Risk": "**Data at rest** that is not encrypted is vulnerable to unauthorized access if the underlying storage media or backups are compromised.\n\nTDE protects against physical theft and unauthorized access to storage systems.", + "Description": "**Alibaba Cloud RDS instances** should have **Transparent Data Encryption (TDE)** enabled. TDE performs real-time encryption and decryption of the database, associated backups, and log files at rest, without requiring changes to the application. This check verifies that TDE is enabled to protect sensitive data stored in the RDS instance from unauthorized physical access.", + "Risk": "**Data at rest** that is not encrypted is vulnerable to unauthorized access if the underlying storage media or backups are compromised, stolen, or improperly decommissioned. Without TDE, attackers with physical or administrative access to the storage layer can read sensitive data directly, impacting **confidentiality** and potentially leading to **data breaches**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/33510.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-sql-database-tde.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-sql-database-tde.html" ], "Remediation": { "Code": { "CLI": "aliyun rds ModifyDBInstanceTDE --DBInstanceId --TDEStatus Enabled", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the target RDS instance.\n3. Go to **Data Security** > **TDE** tab.\n4. Find TDE Status and click the switch next to **Disabled**.\n5. Choose automatically generated key or custom key.\n6. Click **Confirm**.", "Terraform": "resource \"alicloud_db_instance\" \"example\" {\n engine = \"MySQL\"\n engine_version = \"8.0\"\n instance_type = \"rds.mysql.s1.small\"\n instance_storage = 20\n tde_status = \"Enabled\"\n}" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. Go to **Data Security** > **TDE** tab\n3. Find TDE Status and click the switch next to **Disabled**\n4. Choose automatically generated key or custom key\n5. Click **Confirm**", + "Text": "Enable **Transparent Data Encryption (TDE)** on all applicable RDS instances to protect data at rest from unauthorized physical access.", "Url": "https://hub.prowler.com/check/rds_instance_tde_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/rds/rds_instance_tde_key_custom/rds_instance_tde_key_custom.metadata.json b/prowler/providers/alibabacloud/services/rds/rds_instance_tde_key_custom/rds_instance_tde_key_custom.metadata.json index 5b911a5ace..24fe497d8d 100644 --- a/prowler/providers/alibabacloud/services/rds/rds_instance_tde_key_custom/rds_instance_tde_key_custom.metadata.json +++ b/prowler/providers/alibabacloud/services/rds/rds_instance_tde_key_custom/rds_instance_tde_key_custom.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "rds_instance_tde_key_custom", - "CheckTitle": "RDS instance TDE protector is encrypted with BYOK (Use your own key)", - "CheckType": [ - "Sensitive file tampering", - "Intrusion into applications" - ], + "CheckTitle": "RDS instance TDE uses a customer-managed key (BYOK)", + "CheckType": [], "ServiceName": "rds", "SubServiceName": "", - "ResourceIdTemplate": "acs:rds:region:account-id:dbinstance/{dbinstance-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudRDSDBInstance", + "ResourceType": "ALIYUN::RDS::DBInstance", "ResourceGroup": "database", - "Description": "**TDE with BYOK** support provides increased transparency and control, increased security with an HSM-backed KMS service, and promotion of separation of duties.\n\nBased on business needs or criticality of data, it is recommended that the TDE protector is encrypted by a key that is managed by the data owner (**BYOK**).", - "Risk": "Using **service-managed keys** means the cloud provider manages the encryption keys. **BYOK (Bring Your Own Key)** gives you full control over the key lifecycle and permissions.\n\nThis ensures that even the cloud provider cannot access your data without your explicit permission.", + "Description": "**Alibaba Cloud RDS instances** with TDE enabled should use a **customer-managed key (BYOK)** rather than a service-managed key. BYOK provides increased transparency and control over the encryption key lifecycle, enhanced security through an HSM-backed **KMS** service, and promotes separation of duties between the data owner and the cloud provider.", + "Risk": "Using **service-managed keys** means the cloud provider manages the encryption keys, limiting the data owner's control over key access and rotation. Without **BYOK (Bring Your Own Key)**, the cloud provider retains the ability to access encrypted data, reducing **confidentiality** assurance and making it harder to enforce **separation of duties** and **key lifecycle management** policies.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/96121.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-tde-with-cmk.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-RDS/enable-tde-with-cmk.html" ], "Remediation": { "Code": { "CLI": "aliyun rds ModifyDBInstanceTDE --DBInstanceId --TDEStatus Enabled --EncryptionKey ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **RDS Console**.\n2. Select the target RDS instance.\n3. Go to **Data Security** > **TDE** tab.\n4. Click the switch next to **Disabled** (or modify existing TDE configuration).\n5. In the displayed dialog box, choose **custom key** and select your KMS key.\n6. Click **Confirm**.", "Terraform": "resource \"alicloud_db_instance\" \"example\" {\n engine = \"MySQL\"\n engine_version = \"8.0\"\n instance_type = \"rds.mysql.s1.small\"\n instance_storage = 20\n tde_status = \"Enabled\"\n encryption_key = alicloud_kms_key.example.id\n}" }, "Recommendation": { - "Text": "1. Log on to the **RDS Console**\n2. Go to **Data Security** > **TDE** tab\n3. Click the switch next to **Disabled**\n4. In the displayed dialog box, choose **custom key**\n5. Click **Confirm**", + "Text": "Configure TDE on RDS instances to use a **customer-managed key (BYOK)** from KMS for full control over the encryption key lifecycle and enhanced security.", "Url": "https://hub.prowler.com/check/rds_instance_tde_key_custom" } }, diff --git a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_advanced_or_enterprise_edition/securitycenter_advanced_or_enterprise_edition.metadata.json b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_advanced_or_enterprise_edition/securitycenter_advanced_or_enterprise_edition.metadata.json index ff90197827..429663bc2a 100644 --- a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_advanced_or_enterprise_edition/securitycenter_advanced_or_enterprise_edition.metadata.json +++ b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_advanced_or_enterprise_edition/securitycenter_advanced_or_enterprise_edition.metadata.json @@ -1,37 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "securitycenter_advanced_or_enterprise_edition", - "CheckTitle": "Security Center is Advanced or Enterprise Edition", - "CheckType": [ - "Suspicious process", - "Webshell", - "Unusual logon", - "Sensitive file tampering", - "Malicious software", - "Precision defense" - ], + "CheckTitle": "Security Center is using Advanced or Enterprise Edition", + "CheckType": [], "ServiceName": "securitycenter", "SubServiceName": "", - "ResourceIdTemplate": "acs:sas::account-id:security-center", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSecurityCenter", + "ResourceType": "ALIYUN::SAS::Instance", "ResourceGroup": "security", - "Description": "The **Advanced or Enterprise Edition** enables threat detection for network and endpoints, providing **malware detection**, **webshell detection**, and **anomaly detection** in Security Center.", - "Risk": "Using **Basic or Free Edition** of Security Center may not provide comprehensive protection against cloud threats.\n\n**Advanced or Enterprise Edition** allows for full protection to defend against cloud threats.", + "Description": "**Alibaba Cloud Security Center** should be running the **Advanced** or **Enterprise Edition** to enable comprehensive threat detection capabilities for network and endpoints. These editions provide **malware detection**, **webshell detection**, **anomaly detection**, and **precision defense** features that are not available in the Basic or Free editions.", + "Risk": "Using the **Basic or Free Edition** of Security Center limits threat detection capabilities to basic vulnerability scanning only. Without the **Advanced or Enterprise Edition**, critical protections such as **malware detection**, **intrusion prevention**, **webshell detection**, and **anomalous behavior analysis** are unavailable, leaving workloads exposed to sophisticated cloud threats.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/product/28498.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SecurityCenter/security-center-plan.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SecurityCenter/security-center-plan.html" ], "Remediation": { "Code": { - "CLI": "Logon to Security Center Console > Select Overview > Click Upgrade > Select Advanced or Enterprise Edition > Finish order placement", + "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **Security Center Console**\n2. Select **Overview**\n3. Click **Upgrade**\n4. Select **Advanced** or **Enterprise Edition**\n5. Finish order placement", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **Security Center Console**\n2. Select **Overview**\n3. Click **Upgrade**\n4. Select **Advanced** or **Enterprise Edition**\n5. Finish order placement", + "Text": "Upgrade Security Center to the Advanced or Enterprise Edition to enable comprehensive threat detection including malware detection, webshell detection, and anomaly detection capabilities.", "Url": "https://hub.prowler.com/check/securitycenter_advanced_or_enterprise_edition" } }, diff --git a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_all_assets_agent_installed/securitycenter_all_assets_agent_installed.metadata.json b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_all_assets_agent_installed/securitycenter_all_assets_agent_installed.metadata.json index 97decc7e0a..bd63eed35f 100644 --- a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_all_assets_agent_installed/securitycenter_all_assets_agent_installed.metadata.json +++ b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_all_assets_agent_installed/securitycenter_all_assets_agent_installed.metadata.json @@ -1,36 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "securitycenter_all_assets_agent_installed", - "CheckTitle": "All assets are installed with security agent", - "CheckType": [ - "Suspicious process", - "Webshell", - "Unusual logon", - "Sensitive file tampering", - "Malicious software" - ], + "CheckTitle": "All assets have the Security Center agent installed", + "CheckType": [], "ServiceName": "securitycenter", "SubServiceName": "", - "ResourceIdTemplate": "acs:sas:region:account-id:machine/{machine-id}", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudSecurityCenterMachine", + "ResourceType": "ALIYUN::SAS::Instance", "ResourceGroup": "security", - "Description": "The endpoint protection of **Security Center** requires an agent to be installed on the endpoint to work. Such an agent-based approach allows the security center to provide comprehensive endpoint intrusion detection and protection capabilities.\n\nThis includes remote logon detection, **webshell detection** and removal, **anomaly detection** (detection of abnormal process behaviors and network connections), and detection of changes in key files and suspicious accounts.", - "Risk": "Assets without **Security Center agent** installed are not protected by endpoint intrusion detection and protection capabilities, leaving them vulnerable to security threats.\n\nUnprotected assets become blind spots in your security monitoring.", + "Description": "**Alibaba Cloud Security Center** requires an agent to be installed on each endpoint to provide comprehensive endpoint intrusion detection and protection capabilities. The agent enables remote logon detection, **webshell detection** and removal, **anomaly detection** of abnormal process behaviors and network connections, and monitoring of changes to key files and suspicious accounts.", + "Risk": "Assets without the **Security Center agent** installed become blind spots in security monitoring, as they are not protected by endpoint intrusion detection capabilities. This leaves them vulnerable to **malware infections**, **unauthorized access**, **webshell attacks**, and **anomalous process execution** without any alerts being generated.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/111650.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SecurityCenter/install-security-agent.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SecurityCenter/install-security-agent.html" ], "Remediation": { "Code": { "CLI": "aliyun sas InstallUninstallAegis --InstanceIds ,", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **Security Center Console**\n2. Select **Settings**\n3. Click **Agent**\n4. On the `Client to be installed` tab, select all items on the list\n5. Click **One-click installation** to install the agent on all assets", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **Security Center Console**\n2. Select **Settings**\n3. Click **Agent**\n4. On the `Client to be installed` tab, select all items on the list\n5. Click **One-click installation** to install the agent on all assets", + "Text": "Install the Security Center agent on all assets to enable comprehensive endpoint intrusion detection and protection, including webshell detection, anomaly detection, and remote logon monitoring.", "Url": "https://hub.prowler.com/check/securitycenter_all_assets_agent_installed" } }, diff --git a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_notification_enabled_high_risk/securitycenter_notification_enabled_high_risk.metadata.json b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_notification_enabled_high_risk/securitycenter_notification_enabled_high_risk.metadata.json index 480e46fb8b..c688baaa48 100644 --- a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_notification_enabled_high_risk/securitycenter_notification_enabled_high_risk.metadata.json +++ b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_notification_enabled_high_risk/securitycenter_notification_enabled_high_risk.metadata.json @@ -1,36 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "securitycenter_notification_enabled_high_risk", - "CheckTitle": "Notification is enabled on all high risk items", - "CheckType": [ - "Suspicious process", - "Webshell", - "Unusual logon", - "Sensitive file tampering", - "Malicious software" - ], + "CheckTitle": "Notifications are enabled for all high-risk items in Security Center", + "CheckType": [], "ServiceName": "securitycenter", "SubServiceName": "", - "ResourceIdTemplate": "acs:sas::account-id:notice-config/{project}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSecurityCenterNoticeConfig", + "ResourceType": "ALIYUN::SAS::Instance", "ResourceGroup": "security", - "Description": "Enable all **risk item notifications** in Vulnerability, Baseline Risks, Alerts, and AccessKey Leak event detection categories.\n\nThis ensures that relevant security operators receive notifications as soon as security events occur.", - "Risk": "Without **notifications enabled** for high-risk items, security operators may not be aware of critical security events in a timely manner, potentially leading to **delayed response** and **increased security exposure**.", + "Description": "**Alibaba Cloud Security Center** should have all **risk item notifications** enabled across Vulnerability, Baseline Risks, Alerts, and AccessKey Leak event detection categories. This ensures that relevant security operators receive notifications as soon as critical security events occur, enabling timely incident response.", + "Risk": "Without **notifications enabled** for high-risk items in Security Center, security operators may not be aware of critical security events such as **vulnerability discoveries**, **baseline violations**, **intrusion alerts**, and **AccessKey leaks** in a timely manner. This leads to **delayed incident response** and **prolonged security exposure**, increasing the potential impact of threats.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/111648.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SecurityCenter/enable-high-risk-item-notifications.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SecurityCenter/enable-high-risk-item-notifications.html" ], "Remediation": { "Code": { "CLI": "aliyun sas ModifyNoticeConfig --Project --Route ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **Security Center Console**\n2. Select **Settings**\n3. Click **Notification**\n4. Enable all high-risk items on Notification setting\n\nRoute values: `1`=text message, `2`=email, `3`=internal message, `4`=text+email, `5`=text+internal, `6`=email+internal, `7`=all methods", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **Security Center Console**\n2. Select **Settings**\n3. Click **Notification**\n4. Enable all high-risk items on Notification setting\n\nRoute values: `1`=text message, `2`=email, `3`=internal message, `4`=text+email, `5`=text+internal, `6`=email+internal, `7`=all methods", + "Text": "Enable notifications for all high-risk items in Security Center including vulnerabilities, baseline risks, alerts, and AccessKey leak detection to ensure timely incident response.", "Url": "https://hub.prowler.com/check/securitycenter_notification_enabled_high_risk" } }, diff --git a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_vulnerability_scan_enabled/securitycenter_vulnerability_scan_enabled.metadata.json b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_vulnerability_scan_enabled/securitycenter_vulnerability_scan_enabled.metadata.json index c302a79842..6d2033f02f 100644 --- a/prowler/providers/alibabacloud/services/securitycenter/securitycenter_vulnerability_scan_enabled/securitycenter_vulnerability_scan_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/securitycenter/securitycenter_vulnerability_scan_enabled/securitycenter_vulnerability_scan_enabled.metadata.json @@ -2,32 +2,29 @@ "Provider": "alibabacloud", "CheckID": "securitycenter_vulnerability_scan_enabled", "CheckTitle": "Scheduled vulnerability scan is enabled on all servers", - "CheckType": [ - "Malicious software", - "Web application threat detection" - ], + "CheckType": [], "ServiceName": "securitycenter", "SubServiceName": "", - "ResourceIdTemplate": "acs:sas::account-id:vulnerability-scan-config", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "AlibabaCloudSecurityCenterVulConfig", + "ResourceType": "ALIYUN::SAS::Instance", "ResourceGroup": "security", - "Description": "Ensure that **scheduled vulnerability scan** is enabled on all servers.\n\nBe sure that vulnerability scanning is performed periodically to discover system vulnerabilities in time.", - "Risk": "Without **scheduled vulnerability scans** enabled, system vulnerabilities may not be discovered in a timely manner, leaving systems exposed to **known security threats** and **exploits**.", + "Description": "**Alibaba Cloud Security Center** should have **scheduled vulnerability scanning** enabled on all servers to periodically discover system vulnerabilities. The scan should cover all vulnerability types including `yum`, `cve`, `sys`, `cms`, and `emg` to ensure comprehensive detection of known security weaknesses across the infrastructure.", + "Risk": "Without **scheduled vulnerability scans** enabled, system vulnerabilities may remain undetected for extended periods. This leaves servers exposed to **known security exploits**, **privilege escalation attacks**, and **malware infections** that target unpatched software, increasing the overall attack surface and risk of compromise.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/109076.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SecurityCenter/enable-scheduled-vulnerability-scan.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SecurityCenter/enable-scheduled-vulnerability-scan.html" ], "Remediation": { "Code": { "CLI": "aliyun sas ModifyVulConfig --Type --Config on", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **Security Center Console**\n2. Select **Vulnerabilities**\n3. Click **Settings**\n4. Apply all types of vulnerabilities (`yum`, `cve`, `sys`, `cms`, `emg`)\n5. Enable **High** (asap) and **Medium** (later) vulnerability scan levels", "Terraform": "" }, "Recommendation": { - "Text": "1. Log on to the **Security Center Console**\n2. Select **Vulnerabilities**\n3. Click **Settings**\n4. Apply all types of vulnerabilities (`yum`, `cve`, `sys`, `cms`, `emg`)\n5. Enable **High** (asap) and **Medium** (later) vulnerability scan levels", + "Text": "Enable scheduled vulnerability scanning on all servers in Security Center, covering all vulnerability types to ensure timely discovery and remediation of known security weaknesses.", "Url": "https://hub.prowler.com/check/securitycenter_vulnerability_scan_enabled" } }, diff --git a/prowler/providers/alibabacloud/services/sls/sls_cloud_firewall_changes_alert_enabled/sls_cloud_firewall_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_cloud_firewall_changes_alert_enabled/sls_cloud_firewall_changes_alert_enabled.metadata.json index 94baf55bc5..a9b7a94dca 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_cloud_firewall_changes_alert_enabled/sls_cloud_firewall_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_cloud_firewall_changes_alert_enabled/sls_cloud_firewall_changes_alert_enabled.metadata.json @@ -2,28 +2,25 @@ "Provider": "alibabacloud", "CheckID": "sls_cloud_firewall_changes_alert_enabled", "CheckTitle": "Log monitoring and alerts are set up for Cloud Firewall changes", - "CheckType": [ - "Suspicious network connection", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "It is recommended that a **metric filter and alarm** be established for **Cloud Firewall** rule changes.", - "Risk": "Monitoring for **Create** or **Update** firewall rule events gives insight into network access changes and may reduce the time it takes to detect **suspicious activity**.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **Cloud Firewall** rule changes. By directing **ActionTrail** logs to SLS with alert rules, real-time monitoring of firewall modifications is achieved. This ensures creation, update, or deletion of Cloud Firewall control policies is promptly detected and reviewed.", + "Risk": "Without monitoring for **Cloud Firewall** changes, unauthorized modifications to firewall rules may go undetected, leading to **network exposure** or blocked legitimate traffic. Failure to detect changes timely increases risk of **data breaches**, **lateral movement**, and **service disruption**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/cloudfirewall-control-policy-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/cloudfirewall-control-policy-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name cloud-firewall-changes --alert-displayname 'Cloud Firewall Changes Alert' --condition 'event.serviceName: CloudFirewall and (event.eventName: CreateControlPolicy or event.eventName: ModifyControlPolicy or event.eventName: DeleteControlPolicy)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for Cloud Firewall changes: `event.serviceName: CloudFirewall and (event.eventName: CreateControlPolicy or event.eventName: ModifyControlPolicy or event.eventName: DeleteControlPolicy)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_customer_created_cmk_changes_alert_enabled/sls_customer_created_cmk_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_customer_created_cmk_changes_alert_enabled/sls_customer_created_cmk_changes_alert_enabled.metadata.json index 2c564f7bdb..0d988e971a 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_customer_created_cmk_changes_alert_enabled/sls_customer_created_cmk_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_customer_created_cmk_changes_alert_enabled/sls_customer_created_cmk_changes_alert_enabled.metadata.json @@ -1,29 +1,26 @@ { "Provider": "alibabacloud", "CheckID": "sls_customer_created_cmk_changes_alert_enabled", - "CheckTitle": "A log monitoring and alerts are set up for disabling or deletion of customer created CMKs", - "CheckType": [ - "Sensitive file tampering", - "Cloud threat detection" - ], + "CheckTitle": "A log monitoring and alert is set up for disabling or deletion of customer created CMKs", + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "Real-time monitoring of API calls can be achieved by directing **ActionTrail Logs** to Log Service and establishing corresponding query and alarms.\n\nIt is recommended that a query and alarm be established for customer-created **KMS keys** which have changed state to disabled or deletion.", - "Risk": "Data encrypted with **disabled or deleted keys** will no longer be accessible.\n\nThis could lead to **data loss** or **business disruption** if keys are inadvertently or maliciously disabled.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for customer-created **KMS CMKs** that are disabled or scheduled for deletion. By directing **ActionTrail** logs to SLS with alert rules, disabling or deletion of encryption keys is promptly detected, preventing accidental or malicious loss of access to encrypted data.", + "Risk": "Without monitoring for **CMK state changes**, data encrypted with **disabled or deleted keys** becomes permanently inaccessible, leading to **data loss**, **business disruption**, and **compliance violations**. Malicious actors could silently disable or schedule deletion of encryption keys, rendering data unrecoverable.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/kms-cmk-config-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/kms-cmk-config-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name cmk-changes --alert-displayname 'CMK Changes Alert' --condition 'event.serviceName: Kms and (event.eventName: DisableKey or event.eventName: ScheduleKeyDeletion)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for CMK changes: `event.serviceName: Kms and (event.eventName: DisableKey or event.eventName: ScheduleKeyDeletion)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_logstore_retention_period/sls_logstore_retention_period.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_logstore_retention_period/sls_logstore_retention_period.metadata.json index 6b5b4e5420..5674c64ac4 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_logstore_retention_period/sls_logstore_retention_period.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_logstore_retention_period/sls_logstore_retention_period.metadata.json @@ -2,28 +2,26 @@ "Provider": "alibabacloud", "CheckID": "sls_logstore_retention_period", "CheckTitle": "Logstore data retention period is set to the recommended period (default 365 days)", - "CheckType": [ - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/logstore/logstore-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSLogStore", + "ResourceType": "ALIYUN::SLS::Logstore", "ResourceGroup": "monitoring", - "Description": "Ensure **Activity Log Retention** is set for **365 days** or greater.", - "Risk": "Logstore lifecycle controls how your activity log is exported and retained. It is recommended to retain your activity log for **365 days or more** to have time to respond to any incidents.\n\nShort retention periods may result in loss of **forensic evidence** needed for security investigations.", + "Description": "**Alibaba Cloud Simple Log Service (SLS)** Logstore data retention should be configured for at least **365 days**. The Logstore retention period controls how long activity logs are stored and available for analysis. Ensuring a minimum retention of `365` days provides sufficient time to investigate security incidents, perform forensic analysis, and meet regulatory compliance requirements.", + "Risk": "Insufficient log retention may result in **loss of forensic evidence** for security investigations. If logs are deleted before an incident is detected, determining the scope and root cause of breaches becomes impossible. Short retention may also cause **compliance violations** with regulations mandating specific durations, affecting **integrity** and **accountability**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/48990.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/sufficient-logstore-data-retention-period.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/sufficient-logstore-data-retention-period.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls update-logstore --project --logstore --ttl 365", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Log on to the **SLS Console**.\n2. Find the project in the **Projects** section.\n3. Click the **Modify** icon next to the target Logstore.\n4. Set the `Data Retention Period` to `365` days or greater.\n5. Click **Save** to apply the changes.", + "Terraform": "resource \"alicloud_log_store\" \"example\" {\n project = alicloud_log_project.example.name\n name = \"example-logstore\"\n retention_period = 365\n}" }, "Recommendation": { "Text": "1. Log on to the **SLS Console**\n2. Find the project in the Projects section\n3. Click **Modify** icon next to the Logstore\n4. Modify the `Data Retention Period` to `365` or greater", diff --git a/prowler/providers/alibabacloud/services/sls/sls_management_console_authentication_failures_alert_enabled/sls_management_console_authentication_failures_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_management_console_authentication_failures_alert_enabled/sls_management_console_authentication_failures_alert_enabled.metadata.json index dac8630a84..6d00bd7813 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_management_console_authentication_failures_alert_enabled/sls_management_console_authentication_failures_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_management_console_authentication_failures_alert_enabled/sls_management_console_authentication_failures_alert_enabled.metadata.json @@ -1,29 +1,26 @@ { "Provider": "alibabacloud", "CheckID": "sls_management_console_authentication_failures_alert_enabled", - "CheckTitle": "A log monitoring and alerts are set up for Management Console authentication failures", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "A log monitoring and alert is set up for Management Console authentication failures", + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "Real-time monitoring of API calls can be achieved by directing **ActionTrail Logs** to Log Service and establishing corresponding query and alarms.\n\nIt is recommended that a query and alarm be established for **failed console authentication attempts**.", - "Risk": "Monitoring **failed console logins** may decrease lead time to detect an attempt to **brute force** a credential, which may provide an indicator (such as source IP) that can be used in other event correlation.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **failed console authentication attempts**. By directing **ActionTrail** logs to SLS with alert rules, repeated login failures are detected promptly, enabling early identification of brute-force or credential stuffing attacks against the Management Console.", + "Risk": "Without monitoring for **failed console authentication**, brute-force and credential stuffing attacks may go undetected, increasing the risk of **unauthorized access**. Failed login monitoring provides source IP indicators for **threat correlation** and proactive blocking, reducing time to detect and respond to **account compromise** attempts.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/account-continuous-login-failures-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/account-continuous-login-failures-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name console-auth-failures --alert-displayname 'Console Authentication Failures Alert' --condition 'event.eventName: ConsoleSignin and event.errorCode: *' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for failed console authentication: `event.eventName: ConsoleSignin and event.errorCode: *`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_management_console_signin_without_mfa_alert_enabled/sls_management_console_signin_without_mfa_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_management_console_signin_without_mfa_alert_enabled/sls_management_console_signin_without_mfa_alert_enabled.metadata.json index 14cb54b236..4d8e962d8b 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_management_console_signin_without_mfa_alert_enabled/sls_management_console_signin_without_mfa_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_management_console_signin_without_mfa_alert_enabled/sls_management_console_signin_without_mfa_alert_enabled.metadata.json @@ -1,29 +1,26 @@ { "Provider": "alibabacloud", "CheckID": "sls_management_console_signin_without_mfa_alert_enabled", - "CheckTitle": "A log monitoring and alerts are set up for Management Console sign-in without MFA", - "CheckType": [ - "Unusual logon", - "Abnormal account" - ], + "CheckTitle": "A log monitoring and alert is set up for Management Console sign-in without MFA", + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "Real-time monitoring of API calls can be achieved by directing **ActionTrail Logs** to Log Service and establishing corresponding query and alarms.\n\nIt is recommended that a query and alarm be established for console logins that are not protected by **multi-factor authentication (MFA)**.", - "Risk": "Monitoring for **single-factor console logins** will increase visibility into accounts that are not protected by MFA.\n\nThis helps identify potential security gaps in authentication enforcement.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for console logins not protected by **MFA**. By directing **ActionTrail** logs to SLS with alert rules, single-factor console sign-in events are detected, helping identify accounts that bypass MFA enforcement policies.", + "Risk": "Without monitoring for **single-factor logins**, accounts not protected by MFA may go unnoticed, increasing risk of **unauthorized access** through compromised credentials. Failure to monitor MFA compliance weakens **authentication posture** and may lead to **privilege escalation** or **data breaches** if an attacker accesses an unprotected account.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/single-factor-console-logins-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/single-factor-console-logins-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name console-signin-no-mfa --alert-displayname 'Console Sign-in Without MFA Alert' --condition 'event.eventName: ConsoleSignin and event.additionalEventData.MFAUsed: false' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for sign-in without MFA: `event.eventName: ConsoleSignin and event.additionalEventData.MFAUsed: false`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_oss_bucket_policy_changes_alert_enabled/sls_oss_bucket_policy_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_oss_bucket_policy_changes_alert_enabled/sls_oss_bucket_policy_changes_alert_enabled.metadata.json index 60c37a3b6f..7f73a78ab5 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_oss_bucket_policy_changes_alert_enabled/sls_oss_bucket_policy_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_oss_bucket_policy_changes_alert_enabled/sls_oss_bucket_policy_changes_alert_enabled.metadata.json @@ -1,29 +1,26 @@ { "Provider": "alibabacloud", "CheckID": "sls_oss_bucket_policy_changes_alert_enabled", - "CheckTitle": "A log monitoring and alerts are set up for OSS bucket policy changes", - "CheckType": [ - "Sensitive file tampering", - "Cloud threat detection" - ], + "CheckTitle": "A log monitoring and alerts is set up for OSS bucket policy changes", + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "Real-time monitoring of API calls can be achieved by directing **ActionTrail Logs** to Log Service and establishing corresponding query and alarms.\n\nIt is recommended that a query and alarm be established for changes to **OSS bucket policies**.", - "Risk": "Monitoring changes to **OSS bucket policies** may reduce time to detect and correct **permissive policies** on sensitive OSS buckets.\n\nThis helps prevent unintended data exposure.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **OSS bucket policy** changes. By directing **ActionTrail** logs to SLS with alert rules, modifications to bucket access policies are detected promptly, enabling quick identification of dangerous permission changes on sensitive storage resources.", + "Risk": "Without monitoring for **OSS bucket policy changes**, malicious modifications may go undetected, leading to **unintended data exposure**. Unauthorized users could access or delete sensitive objects. Delayed detection increases risk of **data breaches**, **data exfiltration**, and **compliance violations** as attackers silently widen access to storage resources.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/oss-bucket-authority-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/oss-bucket-authority-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name oss-bucket-policy-changes --alert-displayname 'OSS Bucket Policy Changes Alert' --condition 'event.serviceName: Oss and (event.eventName: PutBucketPolicy or event.eventName: DeleteBucketPolicy)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for OSS bucket policy changes: `event.serviceName: Oss and (event.eventName: PutBucketPolicy or event.eventName: DeleteBucketPolicy)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_oss_permission_changes_alert_enabled/sls_oss_permission_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_oss_permission_changes_alert_enabled/sls_oss_permission_changes_alert_enabled.metadata.json index 2ca4e36872..88c8294844 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_oss_permission_changes_alert_enabled/sls_oss_permission_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_oss_permission_changes_alert_enabled/sls_oss_permission_changes_alert_enabled.metadata.json @@ -2,28 +2,25 @@ "Provider": "alibabacloud", "CheckID": "sls_oss_permission_changes_alert_enabled", "CheckTitle": "Log monitoring and alerts are set up for OSS permission changes", - "CheckType": [ - "Sensitive file tampering", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "It is recommended that a **metric filter and alarm** be established for **OSS Bucket RAM** changes.", - "Risk": "Monitoring changes to **OSS permissions** may reduce time to detect and correct permissions on sensitive OSS buckets and objects inside the bucket.\n\nThis helps prevent **unauthorized access** to stored data.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **OSS Bucket RAM** permission changes. By directing **ActionTrail** logs to SLS with alert rules, OSS permission modifications are monitored in real time. This ensures changes to bucket access controls and RAM policies affecting OSS are promptly detected.", + "Risk": "Without monitoring for **OSS permission changes**, unauthorized modifications to bucket access controls may go undetected, allowing attackers **unauthorized access** to sensitive objects. Delayed detection increases risk of **data exfiltration**, **data tampering**, and **compliance violations**, compromising confidentiality and integrity of stored data.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/oss-bucket-permission-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/oss-bucket-permission-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name oss-permission-changes --alert-displayname 'OSS Permission Changes Alert' --condition 'event.serviceName: Oss and (event.eventName: PutBucketAcl or event.eventName: PutObjectAcl)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for OSS permission changes: `event.serviceName: Oss and (event.eventName: PutBucketAcl or event.eventName: PutObjectAcl)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_ram_role_changes_alert_enabled/sls_ram_role_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_ram_role_changes_alert_enabled/sls_ram_role_changes_alert_enabled.metadata.json index a30d12cd0b..ea1e139646 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_ram_role_changes_alert_enabled/sls_ram_role_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_ram_role_changes_alert_enabled/sls_ram_role_changes_alert_enabled.metadata.json @@ -2,28 +2,25 @@ "Provider": "alibabacloud", "CheckID": "sls_ram_role_changes_alert_enabled", "CheckTitle": "Log monitoring and alerts are set up for RAM Role changes", - "CheckType": [ - "Abnormal account", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "It is recommended that a query and alarm be established for **RAM Role** creation, deletion, and updating activities.", - "Risk": "Monitoring **role creation**, **deletion**, and **updating** activities will help in identifying potential **malicious actions** at an early stage.\n\nUnauthorized role changes could lead to privilege escalation.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **RAM Role** creation, deletion, and update activities. By directing **ActionTrail** logs to SLS with alert rules, role changes are monitored in real time. This ensures RAM role modifications are detected promptly, enabling early identification of unauthorized privilege escalation.", + "Risk": "Without monitoring for **RAM role changes**, unauthorized creation, modification, or deletion of roles may go undetected, enabling **privilege escalation** where attackers gain elevated access. Undetected role changes compromise IAM **integrity** and may result in **unauthorized access** to sensitive data and services across the cloud environment.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/ram-policy-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/ram-policy-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name ram-role-changes --alert-displayname 'RAM Role Changes Alert' --condition 'event.serviceName: Ram and (event.eventName: CreateRole or event.eventName: DeleteRole or event.eventName: UpdateRole or event.eventName: AttachPolicyToRole or event.eventName: DetachPolicyFromRole)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for RAM role changes: `event.serviceName: Ram and (event.eventName: CreateRole or event.eventName: DeleteRole or event.eventName: UpdateRole or event.eventName: AttachPolicyToRole or event.eventName: DetachPolicyFromRole)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_rds_instance_configuration_changes_alert_enabled/sls_rds_instance_configuration_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_rds_instance_configuration_changes_alert_enabled/sls_rds_instance_configuration_changes_alert_enabled.metadata.json index d196b1802d..dd58567318 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_rds_instance_configuration_changes_alert_enabled/sls_rds_instance_configuration_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_rds_instance_configuration_changes_alert_enabled/sls_rds_instance_configuration_changes_alert_enabled.metadata.json @@ -2,28 +2,25 @@ "Provider": "alibabacloud", "CheckID": "sls_rds_instance_configuration_changes_alert_enabled", "CheckTitle": "Log monitoring and alerts are set up for RDS instance configuration changes", - "CheckType": [ - "Intrusion into applications", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "It is recommended that a **metric filter and alarm** be established for **RDS Instance** configuration changes.", - "Risk": "Monitoring changes to **RDS Instance configuration** may reduce time to detect and correct **misconfigurations** done on database servers.\n\nThis helps prevent security gaps in database deployments.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **RDS Instance** configuration changes. By directing **ActionTrail** logs to SLS with alert rules, database configuration modifications are monitored in real time. This ensures changes to security parameters, network settings, or access controls are promptly detected.", + "Risk": "Without monitoring for **RDS configuration changes**, unauthorized modifications may go undetected, leading to **security misconfigurations** such as enabling public access or disabling encryption. Delayed detection increases risk of **data breaches**, **unauthorized database access**, and **service disruption**, compromising **confidentiality** and **integrity** of stored data.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/rds-instance-config-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/rds-instance-config-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name rds-config-changes --alert-displayname 'RDS Instance Configuration Changes Alert' --condition 'event.serviceName: Rds and (event.eventName: ModifyDBInstanceSpec or event.eventName: ModifySecurityIps or event.eventName: ModifyDBInstanceNetworkType)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for RDS configuration changes: `event.serviceName: Rds and (event.eventName: ModifyDBInstanceSpec or event.eventName: ModifySecurityIps or event.eventName: ModifyDBInstanceNetworkType)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_root_account_usage_alert_enabled/sls_root_account_usage_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_root_account_usage_alert_enabled/sls_root_account_usage_alert_enabled.metadata.json index e6d271b482..ddb716a2cf 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_root_account_usage_alert_enabled/sls_root_account_usage_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_root_account_usage_alert_enabled/sls_root_account_usage_alert_enabled.metadata.json @@ -1,29 +1,26 @@ { "Provider": "alibabacloud", "CheckID": "sls_root_account_usage_alert_enabled", - "CheckTitle": "A log monitoring and alerts are set up for usage of root account", - "CheckType": [ - "Unusual logon", - "Cloud threat detection" - ], + "CheckTitle": "A log monitoring and alert is set up for usage of root account", + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "Real-time monitoring of API calls can be achieved by directing **ActionTrail Logs** to Log Service and establishing corresponding query and alarms.\n\nIt is recommended that a query and alarm be established for **root account login** attempts.", - "Risk": "Monitoring for **root account logins** will provide visibility into the use of a fully privileged account and an opportunity to reduce its use.\n\nRoot account usage should be minimized and closely monitored.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **root account login** attempts. By directing **ActionTrail** logs to SLS with alert rules, usage of the fully privileged root account is detected promptly, supporting least privilege and enabling timely review of root-level operations.", + "Risk": "Without monitoring for **root account usage**, activities by the most privileged account may go unnoticed. The root account has unrestricted access to all resources, making it a high-value target. Failure to detect unauthorized usage increases risk of **account takeover**, **data destruction**, and **irreversible changes** compromising **confidentiality**, **integrity**, and **availability**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/root-account-login-frequent-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/root-account-login-frequent-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name root-account-usage --alert-displayname 'Root Account Usage Alert' --condition 'event.userIdentity.type: root-account' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for root account usage: `event.userIdentity.type: root-account`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_security_group_changes_alert_enabled/sls_security_group_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_security_group_changes_alert_enabled/sls_security_group_changes_alert_enabled.metadata.json index eca2fd9b53..1cbf78d7c9 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_security_group_changes_alert_enabled/sls_security_group_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_security_group_changes_alert_enabled/sls_security_group_changes_alert_enabled.metadata.json @@ -1,29 +1,26 @@ { "Provider": "alibabacloud", "CheckID": "sls_security_group_changes_alert_enabled", - "CheckTitle": "A log monitoring and alerts are set up for security group changes", - "CheckType": [ - "Suspicious network connection", - "Cloud threat detection" - ], + "CheckTitle": "A log monitoring and alert is set up for security group changes", + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "Real-time monitoring of API calls can be achieved by directing **ActionTrail Logs** to Log Service and establishing corresponding query and alarms.\n\n**Security Groups** are a stateful packet filter that controls ingress and egress traffic within a VPC. It is recommended that a query and alarm be established for changes to Security Groups.", - "Risk": "Monitoring changes to **security groups** will help ensure that resources and services are not unintentionally exposed.\n\nUnauthorized security group modifications could lead to **network exposure** and **unauthorized access**.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **Security Group** changes. By directing **ActionTrail** logs to SLS with alert rules, real-time monitoring is achieved. **Security Groups** are stateful packet filters controlling VPC ingress and egress traffic; monitoring their changes ensures network access modifications are promptly detected.", + "Risk": "Without monitoring for **security group changes**, unauthorized modifications to network access controls may go undetected, leading to resources being **exposed** to untrusted networks. This increases risk of **network exposure**, **unauthorized access**, and **lateral movement**, potentially compromising **confidentiality** and **availability** of critical services.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/security-group-config-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/security-group-config-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name security-group-changes --alert-displayname 'Security Group Changes Alert' --condition 'event.serviceName: Ecs and (event.eventName: AuthorizeSecurityGroup or event.eventName: RevokeSecurityGroup or event.eventName: CreateSecurityGroup or event.eventName: DeleteSecurityGroup)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for security group changes: `event.serviceName: Ecs and (event.eventName: AuthorizeSecurityGroup or event.eventName: RevokeSecurityGroup or event.eventName: CreateSecurityGroup or event.eventName: DeleteSecurityGroup)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_unauthorized_api_calls_alert_enabled/sls_unauthorized_api_calls_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_unauthorized_api_calls_alert_enabled/sls_unauthorized_api_calls_alert_enabled.metadata.json index 5115561aba..de9b3e4e0f 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_unauthorized_api_calls_alert_enabled/sls_unauthorized_api_calls_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_unauthorized_api_calls_alert_enabled/sls_unauthorized_api_calls_alert_enabled.metadata.json @@ -1,29 +1,26 @@ { "Provider": "alibabacloud", "CheckID": "sls_unauthorized_api_calls_alert_enabled", - "CheckTitle": "A log monitoring and alerts are set up for unauthorized API calls", - "CheckType": [ - "Unusual logon", - "Cloud threat detection" - ], + "CheckTitle": "A log monitoring and alert is set up for unauthorized API calls", + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "Real-time monitoring of API calls can be achieved by directing **ActionTrail Logs** to Log Service and establishing corresponding query and alarms.\n\nIt is recommended that a query and alarm be established for **unauthorized API calls**.", - "Risk": "Monitoring **unauthorized API calls** will help reveal application errors and may reduce time to detect **malicious activity**.\n\nThis is essential for early detection of potential security breaches.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **unauthorized API calls**. By directing **ActionTrail** logs to SLS with alert rules, API calls resulting in unauthorized errors are detected promptly, helping identify misconfigured applications, compromised credentials, or active attacker reconnaissance.", + "Risk": "Without monitoring for **unauthorized API calls**, failed access patterns may go undetected. These often indicate **malicious activity** such as permission probing or exploiting misconfigured access controls. Delayed detection increases risk of **security breaches** by delaying identification of compromised credentials and **privilege escalation** attempts.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/unauthorized-api-calls-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/unauthorized-api-calls-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name unauthorized-api-calls --alert-displayname 'Unauthorized API Calls Alert' --condition 'event.errorCode: Forbidden or event.errorCode: AccessDenied' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for unauthorized API calls: `event.errorCode: Forbidden or event.errorCode: AccessDenied`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_vpc_changes_alert_enabled/sls_vpc_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_vpc_changes_alert_enabled/sls_vpc_changes_alert_enabled.metadata.json index e922cd50c9..85e0860554 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_vpc_changes_alert_enabled/sls_vpc_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_vpc_changes_alert_enabled/sls_vpc_changes_alert_enabled.metadata.json @@ -2,28 +2,25 @@ "Provider": "alibabacloud", "CheckID": "sls_vpc_changes_alert_enabled", "CheckTitle": "Log monitoring and alerts are set up for VPC changes", - "CheckType": [ - "Suspicious network connection", - "Cloud threat detection" - ], + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "It is recommended that a **log search/analysis query and alarm** be established for **VPC changes**.", - "Risk": "Monitoring changes to **VPC** will help ensure VPC traffic flow is not getting impacted.\n\nUnauthorized VPC modifications could disrupt network connectivity or create security vulnerabilities.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **VPC** changes. By directing **ActionTrail** logs to SLS with alert rules, VPC modifications are monitored in real time. This ensures creation, deletion, or modification of VPCs and associated components is promptly detected.", + "Risk": "Without monitoring for **VPC changes**, unauthorized modifications to network infrastructure may go undetected, disrupting **connectivity**, creating **vulnerabilities**, or exposing internal resources. This increases risk of **service disruption**, **data interception**, and **lateral movement**, compromising **confidentiality** and **availability** of connected resources.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/vpc-config-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/vpc-config-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name vpc-changes --alert-displayname 'VPC Changes Alert' --condition 'event.serviceName: Vpc and (event.eventName: CreateVpc or event.eventName: DeleteVpc or event.eventName: ModifyVpcAttribute)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for VPC changes: `event.serviceName: Vpc and (event.eventName: CreateVpc or event.eventName: DeleteVpc or event.eventName: ModifyVpcAttribute)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/sls/sls_vpc_network_route_changes_alert_enabled/sls_vpc_network_route_changes_alert_enabled.metadata.json b/prowler/providers/alibabacloud/services/sls/sls_vpc_network_route_changes_alert_enabled/sls_vpc_network_route_changes_alert_enabled.metadata.json index fccd21af53..7067f096a6 100644 --- a/prowler/providers/alibabacloud/services/sls/sls_vpc_network_route_changes_alert_enabled/sls_vpc_network_route_changes_alert_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/sls/sls_vpc_network_route_changes_alert_enabled/sls_vpc_network_route_changes_alert_enabled.metadata.json @@ -1,29 +1,26 @@ { "Provider": "alibabacloud", "CheckID": "sls_vpc_network_route_changes_alert_enabled", - "CheckTitle": "Log monitoring and alerts are set up for VPC network route changes", - "CheckType": [ - "Suspicious network connection", - "Cloud threat detection" - ], + "CheckTitle": "A log monitoring and alert is set up for VPC network route changes", + "CheckType": [], "ServiceName": "sls", "SubServiceName": "", - "ResourceIdTemplate": "acs:log:region:account-id:project/project-name/alert/alert-name", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudSLSAlert", + "ResourceType": "ALIYUN::SLS::Alert", "ResourceGroup": "monitoring", - "Description": "It is recommended that a **metric filter and alarm** be established for **VPC network route** changes.", - "Risk": "Monitoring changes to **route tables** will help ensure that all VPC traffic flows through an expected path.\n\nUnauthorized route changes could redirect traffic through malicious intermediaries.", + "Description": "**Alibaba Cloud SLS** should have an alarm configured for **VPC network route** changes. By directing **ActionTrail** logs to SLS with alert rules, route table modifications are monitored in real time. This ensures creation, deletion, or modification of route entries is detected, verifying VPC traffic flows through expected paths.", + "Risk": "Without monitoring for **route table changes**, unauthorized route modifications may go undetected, allowing attackers to redirect traffic through **malicious intermediaries**. This increases risk of **man-in-the-middle attacks**, **data exfiltration**, and **service disruption** as traffic is diverted from intended destinations, compromising **confidentiality** and **integrity**.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/en/doc-detail/91784.htm", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/vpc-network-route-changes-alert.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-SLS/vpc-network-route-changes-alert.html" ], "Remediation": { "Code": { - "CLI": "", + "CLI": "aliyun sls create-alert --project --alert-name vpc-route-changes --alert-displayname 'VPC Network Route Changes Alert' --condition 'event.serviceName: Vpc and (event.eventName: CreateRouteEntry or event.eventName: DeleteRouteEntry or event.eventName: ModifyRouteEntry)' --dashboard --schedule '{\"type\":\"FixedRate\",\"interval\":\"1m\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **SLS Console**.\n2. Ensure **ActionTrail** is enabled and delivering logs to a **Log Service** project.\n3. Navigate to the project receiving ActionTrail logs.\n4. Select **Alerts** and click **Create Alert Rule**.\n5. Configure the query to filter for VPC network route changes: `event.serviceName: Vpc and (event.eventName: CreateRouteEntry or event.eventName: DeleteRouteEntry or event.eventName: ModifyRouteEntry)`.\n6. Set the alert **schedule**, **notification method**, and **severity**.\n7. Save and enable the alert rule.", "Terraform": "" }, "Recommendation": { diff --git a/prowler/providers/alibabacloud/services/vpc/vpc_flow_logs_enabled/vpc_flow_logs_enabled.metadata.json b/prowler/providers/alibabacloud/services/vpc/vpc_flow_logs_enabled/vpc_flow_logs_enabled.metadata.json index c150689c06..414bc8b80a 100644 --- a/prowler/providers/alibabacloud/services/vpc/vpc_flow_logs_enabled/vpc_flow_logs_enabled.metadata.json +++ b/prowler/providers/alibabacloud/services/vpc/vpc_flow_logs_enabled/vpc_flow_logs_enabled.metadata.json @@ -1,33 +1,30 @@ { "Provider": "alibabacloud", "CheckID": "vpc_flow_logs_enabled", - "CheckTitle": "VPC flow logging is enabled in all VPCs", - "CheckType": [ - "Suspicious network connection", - "Cloud threat detection" - ], + "CheckTitle": "VPC flow logging is enabled for all VPCs", + "CheckType": [], "ServiceName": "vpc", "SubServiceName": "", - "ResourceIdTemplate": "acs:vpc:region:account-id:vpc/{vpc-id}", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "AlibabaCloudVPC", + "ResourceType": "ALIYUN::VPC::FlowLog", "ResourceGroup": "network", - "Description": "You can use the **flow log function** to monitor the IP traffic information for an ENI, a VSwitch, or a VPC.\n\nIf you create a flow log for a VSwitch or a VPC, all the **Elastic Network Interfaces**, including the newly created ones, are monitored. Such flow log data is stored in **Log Service**, where you can view and analyze IP traffic information. It is recommended that VPC Flow Logs be enabled for packet \"Rejects\" for VPCs.", - "Risk": "**VPC Flow Logs** provide visibility into network traffic that traverses the VPC and can be used to detect **anomalous traffic** or provide insight during security workflows.\n\nWithout flow logs, it is difficult to investigate network-based security incidents.", + "Description": "**Alibaba Cloud VPC Flow Logs** capture IP traffic information for Elastic Network Interfaces, VSwitches, or entire VPCs. When a flow log is created for a VPC, all ENIs within it, including newly created ones, are automatically monitored. Flow log data is stored in **Log Service** where it can be viewed and analyzed for security and operational purposes.", + "Risk": "Without **VPC Flow Logs** enabled, there is no visibility into network traffic traversing the VPC. This prevents detection of **anomalous traffic patterns**, **unauthorized network connections**, and **data exfiltration attempts**, and severely limits the ability to investigate network-based security incidents.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.alibabacloud.com/help/doc-detail/90628.html", - "https://www.trendmicro.com/cloudoneconformity/knowledge-base/alibaba-cloud/AlibabaCloud-VPC/enable-flow-logs.html" + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/alibaba-cloud/AlibabaCloud-VPC/enable-flow-logs.html" ], "Remediation": { "Code": { "CLI": "aliyun vpc CreateFlowLog --ResourceId --ResourceType VPC --FlowLogName --LogStoreName --ProjectName ", "NativeIaC": "", - "Other": "", + "Other": "1. Log on to the **VPC Console**\n2. In the left-side navigation pane, click **FlowLog**\n3. Click **Create Flow Log**\n4. Select the target VPC as the resource\n5. Configure the Log Service project and logstore for storing flow log data\n6. Click **OK** to enable flow logging", "Terraform": "resource \"alicloud_vpc_flow_log\" \"example\" {\n flow_log_name = \"example-flow-log\"\n resource_type = \"VPC\"\n resource_id = alicloud_vpc.example.id\n traffic_type = \"All\"\n project_name = alicloud_log_project.example.project_name\n log_store_name = alicloud_log_store.example.logstore_name\n}" }, "Recommendation": { - "Text": "1. Log on to the **VPC Console**\n2. In the left-side navigation pane, click **FlowLog**\n3. Follow the instructions to create FlowLog for each of your VPCs", + "Text": "Enable VPC Flow Logs for all VPCs to capture IP traffic information and store it in Log Service for security analysis, anomaly detection, and incident response.", "Url": "https://hub.prowler.com/check/vpc_flow_logs_enabled" } }, diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index 556b2f33c3..9f0afe97f3 100644 --- a/prowler/providers/aws/aws_provider.py +++ b/prowler/providers/aws/aws_provider.py @@ -435,14 +435,16 @@ class AwsProvider(Provider): f"Getting AWS Organizations metadata for account {aws_account_id}" ) - organizations_metadata, list_tags_for_resource = get_organizations_metadata( - aws_account_id=aws_account_id, - session=organizations_session, + organizations_metadata, list_tags_for_resource, ou_metadata = ( + get_organizations_metadata( + aws_account_id=aws_account_id, + session=organizations_session, + ) ) if organizations_metadata: organizations_metadata = parse_organizations_metadata( - organizations_metadata, list_tags_for_resource + organizations_metadata, list_tags_for_resource, ou_metadata ) logger.info( f"AWS Organizations metadata retrieved for account {aws_account_id}" @@ -940,20 +942,23 @@ class AwsProvider(Provider): ) raise error - def get_default_region(self, service: str) -> str: - """get_default_region returns the default region based on the profile and audited service regions + def get_default_region(self, service: str, global_service: bool = False) -> str: + """get_default_region returns the default region based on the profile and audited service regions. + + For global services (CloudFront, Route53, Shield, FMS) the partition's + global region is always returned, ignoring profile and audited regions. Args: - service: The AWS service name + - global_service: If True, return the partition's global region directly Returns: - str: The default region for the given service - - Example: - service = "ec2" - default_region = get_default_region(service) """ try: + if global_service: + return self.get_global_region() + service_regions = AwsProvider.get_available_aws_service_regions( service, self._identity.partition, self._identity.audited_regions ) diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index db1e4c05ff..297921370d 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -224,6 +224,23 @@ "aws-us-gov": [] } }, + "aiops": { + "regions": { + "aws": [ + "ap-northeast-1", + "ap-south-1", + "ap-southeast-1", + "ap-southeast-2", + "ap-southeast-5", + "ap-southeast-7", + "eu-north-1", + "eu-south-2" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-us-gov": [] + } + }, "aiq": { "regions": { "aws": [ @@ -1567,12 +1584,15 @@ "bedrock-data-automation": { "regions": { "aws": [ + "ap-northeast-1", "ap-south-1", "ap-southeast-2", + "ca-central-1", "eu-central-1", "eu-west-1", "eu-west-2", "us-east-1", + "us-east-2", "us-west-2" ], "aws-cn": [], @@ -1614,6 +1634,16 @@ ] } }, + "billing": { + "regions": { + "aws": [ + "us-east-1" + ], + "aws-cn": [], + "aws-eusc": [], + "aws-us-gov": [] + } + }, "billingconductor": { "regions": { "aws": [ @@ -1641,20 +1671,8 @@ "budgets": { "regions": { "aws": [ - "ap-northeast-1", - "ap-northeast-2", - "ap-south-1", - "ap-southeast-1", - "ap-southeast-2", "ca-central-1", - "eu-central-1", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "sa-east-1", "us-east-1", - "us-east-2", - "us-west-1", "us-west-2" ], "aws-cn": [ @@ -2727,6 +2745,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2737,6 +2756,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -2821,6 +2841,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2831,6 +2852,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -3406,7 +3428,6 @@ "datazone": { "regions": { "aws": [ - "af-south-1", "ap-east-1", "ap-northeast-1", "ap-northeast-2", @@ -3419,7 +3440,6 @@ "eu-central-1", "eu-central-2", "eu-north-1", - "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", @@ -3729,10 +3749,12 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", "ap-southeast-5", "ap-southeast-7", "ca-central-1", "eu-central-1", + "eu-central-2", "eu-north-1", "eu-south-1", "eu-south-2", @@ -3857,6 +3879,10 @@ "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", + "ap-southeast-2", + "ap-southeast-4", + "ca-central-1", + "ca-west-1", "eu-central-1", "eu-west-1", "eu-west-2", @@ -5080,6 +5106,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5817,6 +5844,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -6225,25 +6253,6 @@ "aws-us-gov": [] } }, - "iotanalytics": { - "regions": { - "aws": [ - "ap-northeast-1", - "ap-south-1", - "ap-southeast-2", - "eu-central-1", - "eu-west-1", - "us-east-1", - "us-east-2", - "us-west-2" - ], - "aws-cn": [ - "cn-north-1" - ], - "aws-eusc": [], - "aws-us-gov": [] - } - }, "iotdeviceadvisor": { "regions": { "aws": [ @@ -6976,6 +6985,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -7000,6 +7010,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -7059,16 +7070,6 @@ ] } }, - "lex-models": { - "regions": { - "aws": [ - "us-east-1" - ], - "aws-cn": [], - "aws-eusc": [], - "aws-us-gov": [] - } - }, "lex-runtime": { "regions": { "aws": [ @@ -7589,6 +7590,18 @@ ] } }, + "marketplace-agreement": { + "regions": { + "aws": [ + "us-east-1" + ], + "aws-cn": [], + "aws-eusc": [ + "eusc-de-east-1" + ], + "aws-us-gov": [] + } + }, "marketplace-catalog": { "regions": { "aws": [ @@ -7671,6 +7684,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-north-1", @@ -7700,6 +7714,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-north-1", @@ -7733,6 +7748,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-north-1", @@ -7906,6 +7922,7 @@ "aws": [ "ap-southeast-2", "eu-west-1", + "eu-west-2", "us-east-1", "us-west-2" ], @@ -8225,20 +8242,27 @@ "mwaa-serverless": { "regions": { "aws": [ + "af-south-1", + "ap-east-1", "ap-northeast-1", "ap-northeast-2", + "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-7", "ca-central-1", "eu-central-1", + "eu-central-2", "eu-north-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", + "us-west-1", "us-west-2" ], "aws-cn": [], @@ -8367,7 +8391,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -8601,22 +8627,33 @@ "observabilityadmin": { "regions": { "aws": [ + "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-3", + "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", + "eu-central-2", "eu-north-1", + "eu-south-1", "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -8632,8 +8669,10 @@ "regions": { "aws": [ "ap-northeast-1", + "ap-southeast-2", "ca-central-1", "eu-central-1", + "eu-west-1", "us-east-1", "us-east-2", "us-west-2" @@ -9056,12 +9095,14 @@ "regions": { "aws": [ "ap-northeast-1", + "ap-south-1", "ap-southeast-1", "ap-southeast-2", "eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", + "eu-west-3", "us-east-1", "us-east-2", "us-west-2" @@ -9438,7 +9479,10 @@ ], "aws-cn": [], "aws-eusc": [], - "aws-us-gov": [] + "aws-us-gov": [ + "us-gov-east-1", + "us-gov-west-1" + ] } }, "quicksight": { @@ -9451,6 +9495,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-central-2", @@ -9825,6 +9870,7 @@ "eu-west-1", "eu-west-2", "il-central-1", + "sa-east-1", "us-east-1", "us-east-2", "us-west-1", @@ -10430,7 +10476,9 @@ "us-west-2" ], "aws-cn": [], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -10747,6 +10795,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -10772,7 +10821,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -11984,6 +12035,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -12422,6 +12474,7 @@ "eu-west-2", "eu-west-3", "me-central-1", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -12503,6 +12556,7 @@ "ap-southeast-2", "ca-central-1", "eu-central-1", + "eu-central-2", "eu-north-1", "eu-west-1", "eu-west-2", diff --git a/prowler/providers/aws/config/check_types.json b/prowler/providers/aws/config/check_types.json new file mode 100644 index 0000000000..ce43c32e23 --- /dev/null +++ b/prowler/providers/aws/config/check_types.json @@ -0,0 +1,88 @@ +{ + "Software and Configuration Checks": { + "Vulnerabilities": { + "CVE": {} + }, + "AWS Security Best Practices": { + "Network Reachability": {}, + "Network Security": {}, + "Runtime Behavior Analysis": {}, + "Data Encryption": {}, + "Encryption at Rest": {}, + "Encryption in Transit": {} + }, + "Industry and Regulatory Standards": { + "AWS Foundational Security Best Practices": {}, + "CIS Host Hardening Benchmarks": {}, + "CIS AWS Foundations Benchmark": {}, + "PCI-DSS": {}, + "Cloud Security Alliance Controls": {}, + "ISO 90001 Controls": {}, + "ISO 27001 Controls": {}, + "ISO 27017 Controls": {}, + "ISO 27018 Controls": {}, + "SOC 1": {}, + "SOC 2": {}, + "HIPAA Controls (USA)": {}, + "NIST 800-53 Controls": {}, + "NIST 800-53 Controls (USA)": {}, + "NIST CSF Controls (USA)": {}, + "IRAP Controls (Australia)": {}, + "K-ISMS Controls (Korea)": {}, + "MTCS Controls (Singapore)": {}, + "FISC Controls (Japan)": {}, + "My Number Act Controls (Japan)": {}, + "ENS Controls (Spain)": {}, + "Cyber Essentials Plus Controls (UK)": {}, + "G-Cloud Controls (UK)": {}, + "C5 Controls (Germany)": {}, + "IT-Grundschutz Controls (Germany)": {}, + "GDPR Controls (Europe)": {}, + "TISAX Controls (Europe)": {} + }, + "Patch Management": {} + }, + "TTPs": { + "Initial Access": { + "Unauthorized Access": {}, + "External Remote Services": {}, + "Valid Accounts": {} + }, + "Execution": {}, + "Persistence": {}, + "Privilege Escalation": {}, + "Defense Evasion": {}, + "Credential Access": {}, + "Discovery": {}, + "Lateral Movement": {}, + "Collection": {}, + "Command and Control": {} + }, + "Effects": { + "Data Exposure": {}, + "Data Exfiltration": {}, + "Data Destruction": {}, + "Denial of Service": {}, + "Resource Consumption": {} + }, + "Unusual Behaviors": { + "Application": {}, + "Network Flow": {}, + "IP address": {}, + "User": {}, + "VM": {}, + "Container": {}, + "Serverless": {}, + "Process": {}, + "Database": {}, + "Data": {} + }, + "Sensitive Data Identifications": { + "PII": {}, + "Passwords": {}, + "Legal": {}, + "Financial": {}, + "Security": {}, + "Business": {} + } +} diff --git a/prowler/providers/aws/lib/organizations/organizations.py b/prowler/providers/aws/lib/organizations/organizations.py index 9e4eefb42a..5e152a790b 100644 --- a/prowler/providers/aws/lib/organizations/organizations.py +++ b/prowler/providers/aws/lib/organizations/organizations.py @@ -5,10 +5,44 @@ from prowler.providers.aws.lib.arn.models import ARN from prowler.providers.aws.models import AWSOrganizationsInfo +def _get_ou_metadata(organizations_client, account_id): + try: + parents = organizations_client.list_parents(ChildId=account_id)["Parents"] + if not parents: + return {"ou_id": "", "ou_path": ""} + + parent = parents[0] + if parent["Type"] == "ROOT": + return {"ou_id": "", "ou_path": ""} + + direct_ou_id = parent["Id"] + path_parts = [] + current_id = direct_ou_id + + while True: + ou_info = organizations_client.describe_organizational_unit( + OrganizationalUnitId=current_id + ) + path_parts.append(ou_info["OrganizationalUnit"]["Name"]) + + parents = organizations_client.list_parents(ChildId=current_id)["Parents"] + if not parents or parents[0]["Type"] == "ROOT": + break + current_id = parents[0]["Id"] + + path_parts.reverse() + return {"ou_id": direct_ou_id, "ou_path": "/".join(path_parts)} + except Exception as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return {} + + def get_organizations_metadata( aws_account_id: str, session: session.Session, -) -> tuple[dict, dict]: +) -> tuple[dict, dict, dict]: try: organizations_client = session.client("organizations") @@ -19,15 +53,19 @@ def get_organizations_metadata( ResourceId=aws_account_id ) - return organizations_metadata, list_tags_for_resource + ou_metadata = _get_ou_metadata(organizations_client, aws_account_id) + + return organizations_metadata, list_tags_for_resource, ou_metadata except Exception as error: logger.warning( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - return {}, {} + return {}, {}, {} -def parse_organizations_metadata(metadata: dict, tags: dict) -> AWSOrganizationsInfo: +def parse_organizations_metadata( + metadata: dict, tags: dict, ou_metadata: dict = None +) -> AWSOrganizationsInfo: try: # Convert Tags dictionary to String account_details_tags = {} @@ -47,6 +85,8 @@ def parse_organizations_metadata(metadata: dict, tags: dict) -> AWSOrganizations organization_arn=aws_organization_arn, organization_id=aws_organization_id, account_tags=account_details_tags, + account_ou_id=ou_metadata.get("ou_id", "") if ou_metadata else "", + account_ou_name=ou_metadata.get("ou_path", "") if ou_metadata else "", ) except Exception as error: logger.warning( diff --git a/prowler/providers/aws/lib/service/service.py b/prowler/providers/aws/lib/service/service.py index 2444bf0b25..1044a21881 100644 --- a/prowler/providers/aws/lib/service/service.py +++ b/prowler/providers/aws/lib/service/service.py @@ -61,7 +61,9 @@ class AWSService: # Get a single region and client if the service needs it (e.g. AWS Global Service) # We cannot include this within an else because some services needs both the regional_clients # and a single client like S3 - self.region = provider.get_default_region(self.service) + self.region = provider.get_default_region( + self.service, global_service=global_service + ) self.client = self.session.client(self.service, self.region) # Thread pool for __threading_call__ diff --git a/prowler/providers/aws/models.py b/prowler/providers/aws/models.py index e90999a69b..488706f682 100644 --- a/prowler/providers/aws/models.py +++ b/prowler/providers/aws/models.py @@ -19,6 +19,8 @@ class AWSOrganizationsInfo: organization_arn: str organization_id: str account_tags: list[str] + account_ou_id: str = "" + account_ou_name: str = "" @dataclass diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/__init__.py b/prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk.metadata.json new file mode 100644 index 0000000000..82e0fc6979 --- /dev/null +++ b/prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "awslambda_function_env_vars_not_encrypted_with_cmk", + "CheckTitle": "Lambda function environment variables are encrypted 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" + ], + "ServiceName": "awslambda", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsLambdaFunction", + "ResourceGroup": "serverless", + "Description": "**AWS Lambda function** environment variables are encrypted at rest using a **customer-managed KMS key (CMK)** rather than the default AWS-managed Lambda service key.\n\nThe presence of a `KMSKeyArn` on the function configuration indicates CMK-based encryption is active.", + "Risk": "Without a CMK, environment variables are encrypted with an AWS-managed key, removing **customer control** over rotation, auditing, and revocation.\n\nIf variables contain secrets or connection strings, loss of key control weakens **confidentiality** and can fail compliance requirements (PCI-DSS, HIPAA, FedRAMP) that mandate customer-controlled encryption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-encryption", + "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html", + "https://docs.aws.amazon.com/kms/latest/developerguide/services-lambda.html" + ], + "Remediation": { + "Code": { + "CLI": "aws lambda update-function-configuration --function-name --kms-key-arn ", + "NativeIaC": "```yaml\nAWSTemplateFormatVersion: '2010-09-09'\nResources:\n LambdaFunction:\n Type: AWS::Lambda::Function\n Properties:\n FunctionName: \n Role: \n Handler: index.handler\n Runtime: python3.12\n Code:\n S3Bucket: \n S3Key: \n KmsKeyArn: \n Environment:\n Variables:\n MY_CONFIG: \n```", + "Other": "1. Create or identify a KMS CMK in the same region as the function\n2. Grant the Lambda execution role `kms:Decrypt` and `kms:GenerateDataKey` on the key\n3. In the Lambda console go to Configuration > Environment variables > Edit\n4. Under Encryption configuration, select your CMK\n5. Save — Lambda re-encrypts all environment variables with the chosen key", + "Terraform": "```hcl\nresource \"aws_kms_key\" \"lambda_env\" {\n description = \"Lambda env var encryption key\"\n enable_key_rotation = true\n deletion_window_in_days = 30\n}\n\nresource \"aws_lambda_function\" \"example\" {\n function_name = \"\"\n role = \"\"\n handler = \"index.handler\"\n runtime = \"python3.12\"\n filename = \"\"\n kms_key_arn = aws_kms_key.lambda_env.arn\n\n environment {\n variables = {\n MY_CONFIG = \"\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Encrypt Lambda environment variables with a customer-managed KMS key to maintain full control over key lifecycle and access.\n- Create a dedicated KMS key per application or per function for blast-radius isolation\n- Enable **automatic key rotation** (`EnableKeyRotation: true`)\n- Grant only the Lambda execution role decrypt access via a key policy condition on `kms:ViaService`\n- Prefer **AWS Secrets Manager** or **SSM Parameter Store (SecureString)** for secrets — environment variables should hold non-secret configuration only", + "Url": "https://hub.prowler.com/check/awslambda_function_env_vars_not_encrypted_with_cmk" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk.py b/prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk.py new file mode 100644 index 0000000000..152859d543 --- /dev/null +++ b/prowler/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client + + +class awslambda_function_env_vars_not_encrypted_with_cmk(Check): + def execute(self): + findings = [] + for function in awslambda_client.functions.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=function) + if not function.environment: + report.status = "PASS" + report.status_extended = ( + f"Lambda function {function.name} has no environment variables." + ) + elif function.kms_key_arn: + report.status = "PASS" + report.status_extended = ( + f"Lambda function {function.name} environment variables are " + f"encrypted with KMS key {function.kms_key_arn}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Lambda function {function.name} has environment variables " + f"but they are not encrypted with a customer-managed KMS key." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/__init__.py b/prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue.metadata.json new file mode 100644 index 0000000000..6d7dbfe6d6 --- /dev/null +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "awslambda_function_no_dead_letter_queue", + "CheckTitle": "Lambda function has a Dead Letter Queue configured", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "awslambda", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsLambdaFunction", + "ResourceGroup": "serverless", + "Description": "**AWS Lambda functions** have a **Dead Letter Queue (DLQ)** configured — an SQS queue or SNS topic that receives records of failed asynchronous invocations.\n\nWithout a DLQ, failed invocations are silently discarded after exhausting retries.", + "Risk": "Without a DLQ, failed asynchronous invocations are permanently lost. This harms **availability** by hiding processing failures, and weakens **integrity** by making it impossible to replay or audit unprocessed events.\n\nIn security-sensitive pipelines (e.g., audit log processors, alerting functions), silent failure can mask security events entirely.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html#invocation-dlq", + "https://docs.aws.amazon.com/securityhub/latest/userguide/lambda-controls.html#lambda-4", + "https://repost.aws/knowledge-center/lambda-dead-letter-queue" + ], + "Remediation": { + "Code": { + "CLI": "aws lambda update-function-configuration --function-name --dead-letter-config TargetArn=", + "NativeIaC": "```yaml\nAWSTemplateFormatVersion: '2010-09-09'\nResources:\n LambdaFunction:\n Type: AWS::Lambda::Function\n Properties:\n FunctionName: \n Role: \n Handler: index.handler\n Runtime: python3.12\n Code:\n S3Bucket: \n S3Key: \n DeadLetterConfig:\n TargetArn: \n```", + "Other": "1. Open the AWS Lambda console and select your function\n2. Go to Configuration > Asynchronous invocation\n3. Under Dead-letter queue service, select SQS or SNS\n4. Choose or create the target queue/topic\n5. Save changes", + "Terraform": "```hcl\nresource \"aws_lambda_function\" \"example\" {\n function_name = \"\"\n role = \"\"\n handler = \"index.handler\"\n runtime = \"python3.12\"\n filename = \"\"\n\n dead_letter_config {\n target_arn = \"\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Configure a Dead Letter Queue for every Lambda function that handles asynchronous invocations.\n- Prefer an **SQS queue** as the DLQ target for retry and replay capability\n- Ensure the Lambda execution role has `sqs:SendMessage` permission on the DLQ\n- Monitor DLQ depth with a CloudWatch alarm to alert on processing failures", + "Url": "https://hub.prowler.com/check/awslambda_function_no_dead_letter_queue" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue.py b/prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue.py new file mode 100644 index 0000000000..49f709676a --- /dev/null +++ b/prowler/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue.py @@ -0,0 +1,17 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client + + +class awslambda_function_no_dead_letter_queue(Check): + def execute(self): + findings = [] + for function in awslambda_client.functions.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=function) + if function.dead_letter_config: + report.status = "PASS" + report.status_extended = f"Lambda function {function.name} has a Dead Letter Queue configured at {function.dead_letter_config.target_arn}." + else: + report.status = "FAIL" + report.status_extended = f"Lambda function {function.name} does not have a Dead Letter Queue configured." + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/__init__.py b/prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers.metadata.json b/prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers.metadata.json new file mode 100644 index 0000000000..c1b9fae2fc --- /dev/null +++ b/prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "awslambda_function_using_cross_account_layers", + "CheckTitle": "Lambda function does not use cross-account layers", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Initial Access" + ], + "ServiceName": "awslambda", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsLambdaFunction", + "ResourceGroup": "serverless", + "Description": "**AWS Lambda functions** use only **layers published within the same AWS account**, rather than layers owned by external accounts.\n\nA Lambda layer bundles shared code or dependencies that are injected into the function execution environment at runtime.", + "Risk": "A layer from an external account is a **supply chain dependency outside your control**. If that account is compromised or the layer is updated maliciously, every consumer function executes attacker code with its IAM role — a direct **privilege escalation** and **lateral movement** path across all functions using that layer.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html", + "https://docs.aws.amazon.com/lambda/latest/dg/best-practices.html", + "https://unit42.paloaltonetworks.com/lambda-layers-supply-chain/" + ], + "Remediation": { + "Code": { + "CLI": "# Copy the cross-account layer version into your own account first:\naws lambda publish-layer-version --layer-name --zip-file fileb://\n# Then update the function to use your own layer ARN:\naws lambda update-function-configuration --function-name --layers ", + "NativeIaC": "```yaml\nAWSTemplateFormatVersion: '2010-09-09'\nResources:\n OwnedLayer:\n Type: AWS::Lambda::LayerVersion\n Properties:\n LayerName: \n Content:\n S3Bucket: \n S3Key: \n LambdaFunction:\n Type: AWS::Lambda::Function\n Properties:\n FunctionName: \n Role: \n Handler: index.handler\n Runtime: python3.12\n Code:\n S3Bucket: \n S3Key: \n Layers:\n - !Ref OwnedLayer\n```", + "Other": "1. Download the cross-account layer ZIP\n2. Publish the layer in your own account: Lambda > Layers > Create layer\n3. Update the function configuration to reference your layer ARN\n4. Remove the cross-account layer ARN from the function", + "Terraform": "```hcl\nresource \"aws_lambda_layer_version\" \"example\" {\n layer_name = \"\"\n filename = \"\"\n}\n\nresource \"aws_lambda_function\" \"example\" {\n function_name = \"\"\n role = \"\"\n handler = \"index.handler\"\n runtime = \"python3.12\"\n filename = \"\"\n\n layers = [aws_lambda_layer_version.example.arn]\n}\n```" + }, + "Recommendation": { + "Text": "Eliminate cross-account layer dependencies by hosting all layers in your own AWS account.\n- Audit all layers with `aws lambda get-function-configuration` and inspect `Layers[].Arn`\n- Extract the account ID from the ARN (field 5 in colon-split) and compare against your account\n- For approved vendor layers, pin to a specific immutable version ARN and review on each update\n- Enforce this via SCP: deny `lambda:UpdateFunctionConfiguration` when `lambda:Layer` ARN does not match your account ID", + "Url": "https://hub.prowler.com/check/awslambda_function_using_cross_account_layers" + } + }, + "Categories": [ + "software-supply-chain" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers.py b/prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers.py new file mode 100644 index 0000000000..1030321fed --- /dev/null +++ b/prowler/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.awslambda.awslambda_client import awslambda_client + + +class awslambda_function_using_cross_account_layers(Check): + def execute(self): + findings = [] + for function in awslambda_client.functions.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=function) + cross_account_layers = [ + layer + for layer in function.layers + if layer.account_id != awslambda_client.audited_account + ] + if not function.layers: + report.status = "PASS" + report.status_extended = ( + f"Lambda function {function.name} does not use any layers." + ) + elif cross_account_layers: + report.status = "FAIL" + layer_arns = ", ".join(layer.arn for layer in cross_account_layers) + report.status_extended = ( + f"Lambda function {function.name} uses cross-account " + f"layer(s): {layer_arns}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Lambda function {function.name} only uses layers " + f"from the same account ({awslambda_client.audited_account})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/aws/services/awslambda/awslambda_service.py b/prowler/providers/aws/services/awslambda/awslambda_service.py index aea2bec272..433a5ab588 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_service.py +++ b/prowler/providers/aws/services/awslambda/awslambda_service.py @@ -23,6 +23,7 @@ class Lambda(AWSService): self._list_tags_for_resource() self.__threading_call__(self._get_policy) self.__threading_call__(self._get_function_url_config) + self.__threading_call__(self._list_event_source_mappings) def _list_functions(self, regional_client): logger.info("Lambda - Listing Functions...") @@ -54,6 +55,19 @@ class Lambda(AWSService): "Variables" ) self.functions[lambda_arn].environment = lambda_environment + if "KMSKeyArn" in function: + self.functions[lambda_arn].kms_key_arn = function[ + "KMSKeyArn" + ] + if "Layers" in function: + self.functions[lambda_arn].layers = [ + Layer(arn=layer["Arn"]) for layer in function["Layers"] + ] + dlq_arn = function.get("DeadLetterConfig", {}).get("TargetArn") + if dlq_arn: + self.functions[lambda_arn].dead_letter_config = ( + DeadLetterConfig(target_arn=dlq_arn) + ) except Exception as error: logger.error( @@ -62,6 +76,33 @@ class Lambda(AWSService): f" {error}" ) + 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"), + ) + ) + except Exception as error: + logger.error( + f"{regional_client.region} --" + f" {error.__class__.__name__}[{error.__traceback__.tb_lineno}]:" + f" {error}" + ) + 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. @@ -192,16 +233,42 @@ class URLConfig(BaseModel): cors_config: URLConfigCORS +class Layer(BaseModel): + arn: str + + @property + def account_id(self) -> str: + """Extract the account ID from the layer ARN.""" + parts = self.arn.split(":") + return parts[4] if len(parts) >= 5 else "" + + +class DeadLetterConfig(BaseModel): + target_arn: str + + +class EventSourceMapping(BaseModel): + uuid: str + event_source_arn: str + state: str + batch_size: Optional[int] = None + starting_position: Optional[str] = None + + class Function(BaseModel): name: str arn: str security_groups: list runtime: Optional[str] = None - environment: dict = None + environment: Optional[dict] = None region: str policy: dict = {} code: LambdaCode = None url_config: URLConfig = None vpc_id: Optional[str] = None subnet_ids: Optional[set] = None + kms_key_arn: Optional[str] = None + layers: list[Layer] = [] + dead_letter_config: Optional[DeadLetterConfig] = None + event_source_mappings: list[EventSourceMapping] = [] tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.metadata.json index c8f9743eda..4c8d87db28 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.metadata.json @@ -12,8 +12,8 @@ "Severity": "medium", "ResourceType": "AwsCloudFrontDistribution", "ResourceGroup": "network", - "Description": "**CloudFront distributions** record viewer requests using either **standard access logs** or an attached **real-time log configuration**.\n\nThe finding evaluates whether logging is configured so request metadata is captured for each distribution.", - "Risk": "Missing **CloudFront logs** blinds monitoring of edge requests, impeding detection of bot abuse, credential stuffing, origin probing, and cache-bypass attempts.\n\nThis delays incident response and weakens evidence for forensics, impacting **confidentiality**, **integrity**, and **availability**.", + "Description": "**CloudFront distributions** record viewer requests using **standard access logs** (S3), **real-time log configurations**, or **Standard Logging v2** via CloudWatch Logs delivery sources.\n\nThe finding evaluates whether at least one logging mechanism is active so request metadata is captured for each distribution.", + "Risk": "Missing **CloudFront logs** blinds monitoring of edge requests, impeding detection of bot abuse, credential stuffing, and cache-bypass attempts.\n\nThis delays incident response and weakens forensic evidence. A delivery source without an active delivery does not count as enabled.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html", diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.py b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.py index a3728731a9..0b1cf547a9 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.py +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled.py @@ -9,14 +9,23 @@ class cloudfront_distributions_logging_enabled(Check): findings = [] for distribution in cloudfront_client.distributions.values(): report = Check_Report_AWS(metadata=self.metadata(), resource=distribution) - if distribution.logging_enabled or ( + has_legacy_logging = distribution.logging_enabled + has_realtime_logging = ( distribution.default_cache_config and distribution.default_cache_config.realtime_log_config_arn - ): + ) + has_v2_logging = distribution.logging_v2_enabled + + if has_legacy_logging or has_realtime_logging or has_v2_logging: + methods = [] + if has_legacy_logging: + methods.append("standard") + if has_realtime_logging: + methods.append("real-time") + if has_v2_logging: + methods.append("v2/CloudWatch") report.status = "PASS" - report.status_extended = ( - f"CloudFront Distribution {distribution.id} has logging enabled." - ) + report.status_extended = f"CloudFront Distribution {distribution.id} has logging enabled via {', '.join(methods)}." else: report.status = "FAIL" report.status_extended = ( diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_access_control/cloudfront_distributions_s3_origin_access_control.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_access_control/cloudfront_distributions_s3_origin_access_control.metadata.json index 140ae674b2..a0e8772842 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_access_control/cloudfront_distributions_s3_origin_access_control.metadata.json +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_s3_origin_access_control/cloudfront_distributions_s3_origin_access_control.metadata.json @@ -35,7 +35,8 @@ } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_service.py b/prowler/providers/aws/services/cloudfront/cloudfront_service.py index b7f85113b4..0826c9adfd 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_service.py +++ b/prowler/providers/aws/services/cloudfront/cloudfront_service.py @@ -16,6 +16,7 @@ class CloudFront(AWSService): self._list_distributions(self.client, self.region) self._get_distribution_config(self.client, self.distributions, self.region) self._list_tags_for_resource(self.client, self.distributions, self.region) + self._get_log_delivery_sources(self.distributions, self.region) def _list_distributions(self, client, region) -> dict: logger.info("CloudFront - Listing Distributions...") @@ -153,6 +154,54 @@ class CloudFront(AWSService): f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_log_delivery_sources(self, distributions, region): + """Check for Standard Logging v2 via CloudWatch Logs delivery sources. + + A delivery source alone is not enough. We must also verify that an + active delivery exists for the source, otherwise logs are not flowing. + """ + logger.info("CloudFront - Checking CloudWatch Logs delivery sources...") + try: + distribution_arns = { + dist.arn: dist_id for dist_id, dist in distributions.items() + } + if not distribution_arns: + return + + # CloudFront delivery sources live in the global region (us-east-1), + # not the profile's default region. + global_region = self.provider.get_global_region() + logs_client = self.session.client("logs", region_name=global_region) + + # Find delivery sources whose resourceArns match a distribution + matching_sources = {} + paginator = logs_client.get_paginator("describe_delivery_sources") + for page in paginator.paginate(): + for source in page.get("deliverySources", []): + if source.get("service") != self.service: + continue + for resource_arn in source.get("resourceArns", []): + if resource_arn in distribution_arns: + source_name = source.get("name", "") + matching_sources[source_name] = resource_arn + + if not matching_sources: + return + + # Verify at least one active delivery exists per source + paginator = logs_client.get_paginator("describe_deliveries") + for page in paginator.paginate(): + for delivery in page.get("deliveries", []): + source_name = delivery.get("deliverySourceName", "") + if source_name in matching_sources: + resource_arn = matching_sources[source_name] + dist_id = distribution_arns[resource_arn] + distributions[dist_id].logging_v2_enabled = True + except Exception as error: + logger.error( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class OriginsSSLProtocols(Enum): SSLv3 = "SSLv3" @@ -207,6 +256,7 @@ class Distribution(BaseModel): id: str region: str logging_enabled: bool = False + logging_v2_enabled: bool = False default_cache_config: Optional[DefaultCacheConfigBehaviour] = None geo_restriction_type: Optional[GeoRestrictionType] = None origins: list[Origin] diff --git a/prowler/providers/aws/services/cloudtrail/cloudtrail_logs_s3_bucket_is_not_publicly_accessible/cloudtrail_logs_s3_bucket_is_not_publicly_accessible.metadata.json b/prowler/providers/aws/services/cloudtrail/cloudtrail_logs_s3_bucket_is_not_publicly_accessible/cloudtrail_logs_s3_bucket_is_not_publicly_accessible.metadata.json index 20d7b45a6c..3e671f496d 100644 --- a/prowler/providers/aws/services/cloudtrail/cloudtrail_logs_s3_bucket_is_not_publicly_accessible/cloudtrail_logs_s3_bucket_is_not_publicly_accessible.metadata.json +++ b/prowler/providers/aws/services/cloudtrail/cloudtrail_logs_s3_bucket_is_not_publicly_accessible/cloudtrail_logs_s3_bucket_is_not_publicly_accessible.metadata.json @@ -4,8 +4,8 @@ "CheckTitle": "CloudTrail trail S3 bucket is not publicly accessible", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", - "Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", "Effects/Data Exposure" ], "ServiceName": "cloudtrail", diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_alarm_actions_enabled/cloudwatch_alarm_actions_enabled.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_alarm_actions_enabled/cloudwatch_alarm_actions_enabled.metadata.json index 26d1f8114a..f4faec42a1 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_alarm_actions_enabled/cloudwatch_alarm_actions_enabled.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_alarm_actions_enabled/cloudwatch_alarm_actions_enabled.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "CloudWatch metric alarm has actions enabled", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "TTPs/Defense Evasion" ], "ServiceName": "cloudwatch", diff --git a/prowler/providers/aws/services/codeartifact/codeartifact_packages_external_public_publishing_disabled/codeartifact_packages_external_public_publishing_disabled.metadata.json b/prowler/providers/aws/services/codeartifact/codeartifact_packages_external_public_publishing_disabled/codeartifact_packages_external_public_publishing_disabled.metadata.json index ff96202d30..3f985747be 100644 --- a/prowler/providers/aws/services/codeartifact/codeartifact_packages_external_public_publishing_disabled/codeartifact_packages_external_public_publishing_disabled.metadata.json +++ b/prowler/providers/aws/services/codeartifact/codeartifact_packages_external_public_publishing_disabled/codeartifact_packages_external_public_publishing_disabled.metadata.json @@ -34,7 +34,8 @@ } }, "Categories": [ - "software-supply-chain" + "software-supply-chain", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/__init__.py b/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.metadata.json b/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.metadata.json index 07ba280f90..d718d125a9 100644 --- a/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.metadata.json +++ b/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.metadata.json @@ -12,7 +12,7 @@ "ResourceType": "AwsCodePipelinePipeline", "ResourceGroup": "devops", "Description": "**CodePipeline pipeline** should configure its **source stage** to use a **private repository** with authenticated connection rather than a public GitHub or GitLab repository. This ensures deployment configurations, build artifacts, and CI/CD logic remain protected from unauthorized access.", - "Risk": "Using **public repositories** as pipeline sources exposes deployment configurations, infrastructure code, and CI/CD workflows to anyone on the internet. \n\nThis increases the risk of **supply chain attacks**, **credential exposure**, and **intellectual property theft**. Adversaries can study deployment patterns, identify security gaps, inject malicious code, or leverage exposed secrets to compromise **confidentiality**, **integrity**, and **availability** of production systems.", + "Risk": "Using **public repositories** as pipeline sources exposes deployment configurations and CI/CD workflows to the internet, increasing risk of **supply chain attacks**, **credential exposure**, and **intellectual property theft**. Adversaries can inject malicious code or leverage exposed secrets to compromise production systems.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/codepipeline/latest/userguide/welcome.html", @@ -31,8 +31,8 @@ } }, "Categories": [ - "supply-chain-security", - "secrets-management" + "software-supply-chain", + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/cognito/cognito_user_pool_waf_acl_attached/cognito_user_pool_waf_acl_attached.metadata.json b/prowler/providers/aws/services/cognito/cognito_user_pool_waf_acl_attached/cognito_user_pool_waf_acl_attached.metadata.json index 24608af13b..4a4b5dff6d 100644 --- a/prowler/providers/aws/services/cognito/cognito_user_pool_waf_acl_attached/cognito_user_pool_waf_acl_attached.metadata.json +++ b/prowler/providers/aws/services/cognito/cognito_user_pool_waf_acl_attached/cognito_user_pool_waf_acl_attached.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "Amazon Cognito user pool is associated with a WAF Web ACL", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "Effects/Denial of Service" ], "ServiceName": "cognito", diff --git a/prowler/providers/aws/services/directoryservice/directoryservice_supported_mfa_radius_enabled/directoryservice_supported_mfa_radius_enabled.metadata.json b/prowler/providers/aws/services/directoryservice/directoryservice_supported_mfa_radius_enabled/directoryservice_supported_mfa_radius_enabled.metadata.json index a022b44f63..4f585e358e 100644 --- a/prowler/providers/aws/services/directoryservice/directoryservice_supported_mfa_radius_enabled/directoryservice_supported_mfa_radius_enabled.metadata.json +++ b/prowler/providers/aws/services/directoryservice/directoryservice_supported_mfa_radius_enabled/directoryservice_supported_mfa_radius_enabled.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "AWS Directory Service directory has RADIUS-based MFA enabled", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "TTPs/Initial Access", "TTPs/Credential Access" ], diff --git a/prowler/providers/aws/services/documentdb/documentdb_cluster_backup_enabled/documentdb_cluster_backup_enabled.metadata.json b/prowler/providers/aws/services/documentdb/documentdb_cluster_backup_enabled/documentdb_cluster_backup_enabled.metadata.json index d5c0c00cbf..03b49b2801 100644 --- a/prowler/providers/aws/services/documentdb/documentdb_cluster_backup_enabled/documentdb_cluster_backup_enabled.metadata.json +++ b/prowler/providers/aws/services/documentdb/documentdb_cluster_backup_enabled/documentdb_cluster_backup_enabled.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "DocumentDB cluster has automated backups enabled with retention period of at least 7 days", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "Effects/Data Destruction" ], "ServiceName": "documentdb", diff --git a/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json index 4d0e38cad9..5bb5e44bc3 100644 --- a/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_instance_port_mysql_exposed_to_internet/ec2_instance_port_mysql_exposed_to_internet.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "EC2 instance does not allow ingress from the Internet to TCP port 3306 (MySQL)", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "TTPs/Initial Access/Unauthorized Access", "Effects/Data Exposure" ], diff --git a/prowler/providers/aws/services/ec2/ec2_networkacl_unused/ec2_networkacl_unused.metadata.json b/prowler/providers/aws/services/ec2/ec2_networkacl_unused/ec2_networkacl_unused.metadata.json index 6f6c696085..13aec73860 100644 --- a/prowler/providers/aws/services/ec2/ec2_networkacl_unused/ec2_networkacl_unused.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_networkacl_unused/ec2_networkacl_unused.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "Non-default network ACL is associated with a subnet", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices" + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "ec2", "SubServiceName": "", diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/__init__.py b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.metadata.json new file mode 100644 index 0000000000..5fdcdcbb46 --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "aws", + "CheckID": "ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip", + "CheckTitle": "Security group does not have any port open to a specific public IP address", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", + "TTPs/Initial Access" + ], + "ServiceName": "ec2", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AwsEc2SecurityGroup", + "ResourceGroup": "network", + "Description": "EC2 security groups with inbound rules allowing traffic from specific globally routable IP addresses to any port or protocol. Wildcard CIDRs (0.0.0.0/0 and ::/0) are excluded as they are covered by the related checks. This targets cases where developers add personal or third-party IPs directly to security groups.", + "Risk": "Ingress rules with specific public IPs can become stale when personnel change or access requirements expire. An attacker with compromised AWS credentials could also add narrow IP rules to gain access on any port, bypassing checks that only look for 0.0.0.0/0 or ::/0.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/vpc/latest/userguide/VPC_SecurityGroups.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "```yaml\n# CloudFormation: security group without public IP ingress\nResources:\n :\n Type: AWS::EC2::SecurityGroup\n Properties:\n GroupDescription: \"SG without public IP ingress\"\n VpcId: \"\"\n SecurityGroupIngress: [] # No inbound rules with public IPs\n```", + "Other": "1. In the AWS Console, go to EC2 > Security Groups\n2. Select the affected security group\n3. Open the Inbound rules tab and click Edit inbound rules\n4. Remove or restrict any rule with a Source that is a public IP address\n5. Click Save rules", + "Terraform": "```hcl\n# Security group with no public IP ingress\nresource \"aws_security_group\" \"\" {\n name = \"\"\n vpc_id = \"\"\n # No ingress blocks with public IP CIDRs\n}\n```" + }, + "Recommendation": { + "Text": "Review all security group rules with specific public IP sources. Remove stale entries for former employees or expired access. Use VPN, AWS Systems Manager Session Manager, or AWS Client VPN instead of direct IP-based access. For third-party integrations, use VPC endpoints or AWS PrivateLink where possible.", + "Url": "https://hub.prowler.com/check/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "ec2_securitygroup_allow_ingress_from_internet_to_all_ports", + "ec2_securitygroup_allow_ingress_from_internet_to_any_port" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.py b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.py new file mode 100644 index 0000000000..c37a3340a4 --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ec2.ec2_client import ec2_client +from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_all_ports import ( + ec2_securitygroup_allow_ingress_from_internet_to_all_ports, +) +from prowler.providers.aws.services.ec2.lib.security_groups import check_security_group +from prowler.providers.aws.services.vpc.vpc_client import vpc_client + + +class ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip(Check): + def execute(self): + findings = [] + for security_group_arn, security_group in ec2_client.security_groups.items(): + # Check if ignoring flag is set and if the VPC and the SG is in use + if ec2_client.provider.scan_unused_services or ( + security_group.vpc_id in vpc_client.vpcs + and vpc_client.vpcs[security_group.vpc_id].in_use + and len(security_group.network_interfaces) > 0 + ): + report = Check_Report_AWS( + metadata=self.metadata(), resource=security_group + ) + report.resource_details = security_group.name + report.status = "PASS" + report.status_extended = f"Security group {security_group.name} ({security_group.id}) does not have any port open to a public IP address." + + # only proceed if check "..._to_all_ports" did not run or did not FAIL to avoid reporting twice + if not ec2_client.is_failed_check( + ec2_securitygroup_allow_ingress_from_internet_to_all_ports.__name__, + security_group_arn, + ): + for ingress_rule in security_group.ingress_rules: + # Skip rules that only contain 0.0.0.0/0 or ::/0 + # (already covered by other SG checks) + wildcard_cidrs = ("0.0.0.0/0", "::/0") + has_specific_ip = any( + r["CidrIp"] not in wildcard_cidrs + for r in ingress_rule.get("IpRanges", []) + ) or any( + r["CidrIpv6"] not in wildcard_cidrs + for r in ingress_rule.get("Ipv6Ranges", []) + ) + if has_specific_ip and check_security_group( + ingress_rule, "-1", any_address=False, all_ports=True + ): + report.status = "FAIL" + report.status_extended = f"Security group {security_group.name} ({security_group.id}) has a port open to a specific public IP address in ingress rule." + break + else: + report.status_extended = f"Security group {security_group.name} ({security_group.id}) has all ports open to the Internet and therefore was not checked against specific public IP ingress rules." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/ec2/ec2_securitygroup_default_restrict_traffic/ec2_securitygroup_default_restrict_traffic.metadata.json b/prowler/providers/aws/services/ec2/ec2_securitygroup_default_restrict_traffic/ec2_securitygroup_default_restrict_traffic.metadata.json index e790e88698..ceeca3ffe0 100644 --- a/prowler/providers/aws/services/ec2/ec2_securitygroup_default_restrict_traffic/ec2_securitygroup_default_restrict_traffic.metadata.json +++ b/prowler/providers/aws/services/ec2/ec2_securitygroup_default_restrict_traffic/ec2_securitygroup_default_restrict_traffic.metadata.json @@ -33,7 +33,8 @@ } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/efs/efs_not_publicly_accessible/efs_not_publicly_accessible.metadata.json b/prowler/providers/aws/services/efs/efs_not_publicly_accessible/efs_not_publicly_accessible.metadata.json index 7bc7c7dc7d..69ed055b3a 100644 --- a/prowler/providers/aws/services/efs/efs_not_publicly_accessible/efs_not_publicly_accessible.metadata.json +++ b/prowler/providers/aws/services/efs/efs_not_publicly_accessible/efs_not_publicly_accessible.metadata.json @@ -32,7 +32,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/elasticache/elasticache_cluster_uses_public_subnet/elasticache_cluster_uses_public_subnet.metadata.json b/prowler/providers/aws/services/elasticache/elasticache_cluster_uses_public_subnet/elasticache_cluster_uses_public_subnet.metadata.json index fd03b1ed54..297649291f 100644 --- a/prowler/providers/aws/services/elasticache/elasticache_cluster_uses_public_subnet/elasticache_cluster_uses_public_subnet.metadata.json +++ b/prowler/providers/aws/services/elasticache/elasticache_cluster_uses_public_subnet/elasticache_cluster_uses_public_subnet.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "ElastiCache cluster is not using public subnets", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "Effects/Data Exposure" ], "ServiceName": "elasticache", diff --git a/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.metadata.json b/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.metadata.json index 5628f6ec62..2b2ae8e87c 100644 --- a/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.metadata.json +++ b/prowler/providers/aws/services/eventbridge/eventbridge_bus_cross_account_access/eventbridge_bus_cross_account_access.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "AWS EventBridge event bus does not allow cross-account access", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "TTPs/Initial Access/Unauthorized Access", "Effects/Data Exposure" ], diff --git a/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/__init__.py b/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.metadata.json b/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.metadata.json new file mode 100644 index 0000000000..530599053d --- /dev/null +++ b/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "glue_etl_jobs_no_secrets_in_arguments", + "CheckTitle": "Glue ETL job has no secrets in default arguments", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "TTPs/Credential Access", + "Effects/Data Exposure", + "Sensitive Data Identifications/Security" + ], + "ServiceName": "glue", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "Other", + "ResourceGroup": "analytics", + "Description": "**AWS Glue ETL jobs** are inspected for **default arguments** (`DefaultArguments`) that resemble **secrets** (keys, tokens, passwords).\n\nSuch values indicate sensitive data is stored directly in job arguments instead of being sourced securely from AWS Secrets Manager or Systems Manager Parameter Store.", + "Risk": "Plaintext secrets in default arguments reduce confidentiality: values can be viewed in consoles, CLI output, and CloudTrail logs. Compromised credentials enable unauthorized AWS actions, data exfiltration, and lateral movement across the environment.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/glue/latest/dg/aws-glue-programming-etl-glue-arguments.html", + "https://docs.aws.amazon.com/glue/latest/webapi/API_Job.html", + "https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html" + ], + "Remediation": { + "Code": { + "CLI": "aws glue update-job --job-name --job-update '{\"DefaultArguments\":{\"--secret_name\":\"{{resolve:secretsmanager:my-secret}}\"}}'", + "NativeIaC": "```yaml\nResources:\n GlueJob:\n Type: AWS::Glue::Job\n Properties:\n Name: \n Role: \n Command:\n Name: glueetl\n ScriptLocation: \"s3:///script.py\"\n DefaultArguments:\n \"--secret_name\": !Sub \"{{resolve:secretsmanager:${MySecret}}}\" # Reference secret from Secrets Manager instead of plaintext\n```", + "Other": "1. Open the AWS Glue console and go to Jobs\n2. Select the job and click Edit\n3. Under Job parameters, identify any arguments containing sensitive values\n4. Store those values in AWS Secrets Manager or Systems Manager Parameter Store\n5. Update the job arguments to reference the secret by name or ARN instead of the plaintext value\n6. Save the job", + "Terraform": "```hcl\nresource \"aws_glue_job\" \"example\" {\n name = \"\"\n role_arn = \"\"\n\n command {\n script_location = \"s3:///script.py\"\n }\n\n default_arguments = {\n \"--secret_name\" = aws_secretsmanager_secret_version.example.secret_string # Reference secret from Secrets Manager instead of plaintext\n }\n}\n```" + }, + "Recommendation": { + "Text": "Store secrets in **AWS Secrets Manager** or **AWS Systems Manager Parameter Store** and reference them by name or ARN in job arguments instead of embedding plaintext values. Enforce **least privilege** on the Glue job IAM role, rotate secrets regularly, and avoid logging or exporting argument values.", + "Url": "https://hub.prowler.com/check/glue_etl_jobs_no_secrets_in_arguments" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} 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 new file mode 100644 index 0000000000..50c92f8619 --- /dev/null +++ b/prowler/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments.py @@ -0,0 +1,52 @@ +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 diff --git a/prowler/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/__init__.py b/prowler/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions.metadata.json b/prowler/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions.metadata.json new file mode 100644 index 0000000000..6ebfa056ad --- /dev/null +++ b/prowler/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "guardduty_delegated_admin_enabled_all_regions", + "CheckTitle": "GuardDuty has delegated admin configured and is enabled in all regions with organization auto-enable", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "guardduty", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsGuardDutyDetector", + "ResourceGroup": "security", + "Description": "**Amazon GuardDuty** has a delegated administrator configured at the organization level, detectors are enabled in all opted-in regions, and organization auto-enable is active for new member accounts.", + "Risk": "Without org-wide **Amazon GuardDuty** configuration, gaps can occur where detectors are enabled in some regions but not others, delegated admin is inconsistent, and new accounts are not auto-enrolled. This fragments **threat visibility**, delays **incident response**, and allows adversaries to exploit unmonitored regions or accounts for **lateral movement** and **data exfiltration**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_organizations.html", + "https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_multi-account.html", + "https://docs.aws.amazon.com/organizations/latest/userguide/services-that-can-integrate-guardduty.html" + ], + "Remediation": { + "Code": { + "CLI": "aws guardduty enable-organization-admin-account --admin-account-id && aws guardduty update-organization-configuration --detector-id --auto-enable-organization-members ALL", + "NativeIaC": "", + "Other": "1. Sign in to the AWS Organizations management account\n2. Open the AWS Organizations console\n3. Navigate to Services > Amazon GuardDuty\n4. Click Register delegated administrator and enter the security account ID\n5. Switch to the delegated admin account\n6. In GuardDuty console, go to Settings > Accounts\n7. Enable auto-enable for all organization members\n8. Repeat detector enablement for all opted-in regions", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **delegated administrator** for GuardDuty via AWS Organizations. Enable GuardDuty detectors in **all opted-in regions** and configure **auto-enable** to automatically enroll new member accounts. This ensures consistent threat detection coverage across the entire organization.", + "Url": "https://hub.prowler.com/check/guardduty_delegated_admin_enabled_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "guardduty_is_enabled", + "guardduty_centrally_managed" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions.py b/prowler/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions.py new file mode 100644 index 0000000000..23065abd2b --- /dev/null +++ b/prowler/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions.py @@ -0,0 +1,76 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.guardduty.guardduty_client import guardduty_client + + +class guardduty_delegated_admin_enabled_all_regions(Check): + """Ensure GuardDuty has a delegated admin and is enabled in all regions. + + This check verifies that: + 1. A delegated administrator account is configured for GuardDuty + 2. GuardDuty detectors are enabled in each region + 3. Organization auto-enable is configured for new member accounts + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check for each region. + """ + findings = [] + + # Build a set of regions that have an organization admin account configured + regions_with_admin = { + admin.region + for admin in guardduty_client.organization_admin_accounts + if admin.admin_status == "ENABLED" + } + + for detector in guardduty_client.detectors: + report = Check_Report_AWS(metadata=self.metadata(), resource=detector) + + # Check if this region has a delegated admin + has_delegated_admin = detector.region in regions_with_admin + + # Check if detector is enabled + detector_enabled = detector.enabled_in_account and detector.status + + # Check if auto-enable is configured for organization members + auto_enable_configured = detector.organization_auto_enable_members in ( + "NEW", + "ALL", + ) + + # Determine overall status + issues = [] + if not has_delegated_admin: + issues.append("no delegated administrator configured") + if not detector_enabled: + issues.append("detector not enabled") + if not auto_enable_configured and detector.organization_config_available: + # Only report auto-enable issue if org config data is available + issues.append("organization auto-enable not configured") + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"GuardDuty in region {detector.region} has issues: " + f"{', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"GuardDuty in region {detector.region} has delegated admin " + f"configured with detector enabled and organization auto-enable active." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + guardduty_client.audit_config.get("mute_non_default_regions", False) + and detector.region != guardduty_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/guardduty/guardduty_service.py b/prowler/providers/aws/services/guardduty/guardduty_service.py index c267771209..bde8725454 100644 --- a/prowler/providers/aws/services/guardduty/guardduty_service.py +++ b/prowler/providers/aws/services/guardduty/guardduty_service.py @@ -1,5 +1,6 @@ from typing import Optional +from botocore.exceptions import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -12,12 +13,17 @@ class GuardDuty(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.detectors = [] + self.organization_admin_accounts = [] self.__threading_call__(self._list_detectors) self.__threading_call__(self._get_detector, self.detectors) self._list_findings() self._list_members() self._get_administrator_account() self._list_tags_for_resource() + self.__threading_call__(self._list_organization_admin_accounts) + self.__threading_call__( + self._describe_organization_configuration, self.detectors + ) def _list_detectors(self, regional_client): logger.info("GuardDuty - listing detectors...") @@ -216,13 +222,97 @@ class GuardDuty(AWSService): f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" ) + def _list_organization_admin_accounts(self, regional_client): + """List GuardDuty delegated administrator accounts for the organization. + + This API is only available to the organization management account or + a delegated administrator account. + """ + logger.info("GuardDuty - listing organization admin accounts...") + try: + paginator = regional_client.get_paginator( + "list_organization_admin_accounts" + ) + for page in paginator.paginate(): + for admin in page.get("AdminAccounts", []): + admin_account = OrganizationAdminAccount( + admin_account_id=admin.get("AdminAccountId"), + admin_status=admin.get("AdminStatus"), + region=regional_client.region, + ) + # Avoid duplicates across regions for the same admin account + if not any( + existing.admin_account_id == admin_account.admin_account_id + and existing.region == admin_account.region + for existing in self.organization_admin_accounts + ): + self.organization_admin_accounts.append(admin_account) + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "BadRequestException", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_organization_configuration(self, detector): + """Describe the organization configuration for a GuardDuty detector. + + This provides information about auto-enable settings for the organization. + """ + logger.info("GuardDuty - describing organization configuration...") + try: + if detector.id and detector.enabled_in_account: + regional_client = self.regional_clients[detector.region] + org_config = regional_client.describe_organization_configuration( + DetectorId=detector.id + ) + detector.organization_auto_enable_members = org_config.get( + "AutoEnableOrganizationMembers", "NONE" + ) + detector.organization_config_available = True + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "BadRequestException", + ): + # Expected when not running from management or delegated admin account + logger.warning( + f"{detector.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{detector.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{detector.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class OrganizationAdminAccount(BaseModel): + """Represents a GuardDuty delegated administrator account.""" + + admin_account_id: str + admin_status: str # ENABLED or DISABLE_IN_PROGRESS + region: str + class Detector(BaseModel): id: str arn: str region: str enabled_in_account: bool - status: bool = None + status: Optional[bool] = None findings: list = [] member_accounts: list = [] administrator_account: str = None @@ -233,3 +323,6 @@ class Detector(BaseModel): eks_runtime_monitoring: bool = False lambda_protection: bool = False ec2_malware_protection: bool = False + # Organization configuration fields + organization_auto_enable_members: str = "NONE" # NEW, ALL, or NONE + organization_config_available: bool = False diff --git a/prowler/providers/aws/services/iam/iam_aws_attached_policy_no_administrative_privileges/iam_aws_attached_policy_no_administrative_privileges.metadata.json b/prowler/providers/aws/services/iam/iam_aws_attached_policy_no_administrative_privileges/iam_aws_attached_policy_no_administrative_privileges.metadata.json index 9c1b95c551..6bad6e8b0e 100644 --- a/prowler/providers/aws/services/iam/iam_aws_attached_policy_no_administrative_privileges/iam_aws_attached_policy_no_administrative_privileges.metadata.json +++ b/prowler/providers/aws/services/iam/iam_aws_attached_policy_no_administrative_privileges/iam_aws_attached_policy_no_administrative_privileges.metadata.json @@ -4,8 +4,8 @@ "CheckTitle": "Attached AWS-managed IAM policy does not allow '*:*' administrative privileges", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", - "Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", "TTPs/Privilege Escalation" ], "ServiceName": "iam", diff --git a/prowler/providers/aws/services/iam/iam_inline_policy_no_full_access_to_kms/iam_inline_policy_no_full_access_to_kms.metadata.json b/prowler/providers/aws/services/iam/iam_inline_policy_no_full_access_to_kms/iam_inline_policy_no_full_access_to_kms.metadata.json index 36b2875172..b49eb0a914 100644 --- a/prowler/providers/aws/services/iam/iam_inline_policy_no_full_access_to_kms/iam_inline_policy_no_full_access_to_kms.metadata.json +++ b/prowler/providers/aws/services/iam/iam_inline_policy_no_full_access_to_kms/iam_inline_policy_no_full_access_to_kms.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "Inline IAM policy does not allow kms:* privileges", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "TTPs/Privilege Escalation", "Effects/Data Exposure" ], diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index 1ff3dbd07a..a8469d3b9d 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -112,8 +112,8 @@ class IAM(AWSService): def _get_roles(self): logger.info("IAM - List Roles...") + roles = [] try: - roles = [] get_roles_paginator = self.client.get_paginator("list_roles") for page in get_roles_paginator.paginate(): for role in page["Roles"]: @@ -142,8 +142,7 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return roles + return roles def _get_credential_report(self): logger.info("IAM - Get Credential Report...") @@ -175,13 +174,12 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return credential_list + return credential_list def _get_groups(self): logger.info("IAM - Get Groups...") + groups = [] try: - groups = [] get_groups_paginator = self.client.get_paginator("list_groups") for page in get_groups_paginator.paginate(): for group in page["Groups"]: @@ -194,25 +192,23 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return groups + return groups def _get_account_summary(self): logger.info("IAM - Get Account Summary...") + account_summary = None try: account_summary = self.client.get_account_summary() except Exception as error: logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - account_summary = None - finally: - return account_summary + return account_summary def _get_password_policy(self): logger.info("IAM - Get Password Policy...") + stored_password_policy = None try: - stored_password_policy = None password_policy = self.client.get_account_password_policy()[ "PasswordPolicy" ] @@ -274,14 +270,13 @@ class IAM(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return stored_password_policy + return stored_password_policy def _get_users(self): logger.info("IAM - Get Users...") + users = [] try: get_users_paginator = self.client.get_paginator("list_users") - users = [] for page in get_users_paginator.paginate(): for user in page["Users"]: if not self.audit_resources or ( @@ -311,13 +306,12 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return users + return users def _list_virtual_mfa_devices(self): logger.info("IAM - List Virtual MFA Devices...") + mfa_devices = [] try: - mfa_devices = [] list_virtual_mfa_devices_paginator = self.client.get_paginator( "list_virtual_mfa_devices" ) @@ -329,8 +323,7 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return mfa_devices + return mfa_devices def _list_attached_group_policies(self): logger.info("IAM - List Attached Group Policies...") @@ -677,12 +670,11 @@ class IAM(AWSService): def _list_entities_role_for_policy(self, policy_arn): logger.info("IAM - List Entities Role For Policy...") + roles = [] try: - roles = [] roles = self.client.list_entities_for_policy( PolicyArn=policy_arn, EntityFilter="Role" )["PolicyRoles"] - return roles except ClientError as error: if error.response["Error"]["Code"] == "AccessDenied": logger.error( @@ -697,18 +689,16 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return roles + return roles def _list_entities_for_policy(self, policy_arn): logger.info("IAM - List Entities For Policy...") + entities = { + "Users": [], + "Groups": [], + "Roles": [], + } try: - entities = { - "Users": [], - "Groups": [], - "Roles": [], - } - paginator = self.client.get_paginator("list_entities_for_policy") for response in paginator.paginate(PolicyArn=policy_arn): entities["Users"].extend( @@ -720,7 +710,6 @@ class IAM(AWSService): entities["Roles"].extend( role["RoleName"] for role in response.get("PolicyRoles", []) ) - return entities except ClientError as error: if error.response["Error"]["Code"] == "AccessDenied": logger.error( @@ -735,13 +724,12 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return entities + return entities def _list_policies(self, scope): logger.info("IAM - List Policies...") + policies = {} try: - policies = {} list_policies_paginator = self.client.get_paginator("list_policies") for page in list_policies_paginator.paginate( Scope=scope, OnlyAttached=False if scope == "Local" else True @@ -762,8 +750,7 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return policies + return policies def _list_policies_version(self, policies): logger.info("IAM - List Policies Version...") @@ -817,8 +804,8 @@ class IAM(AWSService): def _list_server_certificates(self) -> list: logger.info("IAM - List Server Certificates...") + server_certificates = [] try: - server_certificates = [] for certificate in self.client.list_server_certificates()[ "ServerCertificateMetadataList" ]: @@ -837,8 +824,7 @@ class IAM(AWSService): logger.error( f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return server_certificates + return server_certificates def _list_tags(self, resource: any): logger.info("IAM - List Tags...") diff --git a/prowler/providers/aws/services/iam/iam_user_two_active_access_key/iam_user_two_active_access_key.py b/prowler/providers/aws/services/iam/iam_user_two_active_access_key/iam_user_two_active_access_key.py index 6788c85322..769f9b86fe 100644 --- a/prowler/providers/aws/services/iam/iam_user_two_active_access_key/iam_user_two_active_access_key.py +++ b/prowler/providers/aws/services/iam/iam_user_two_active_access_key/iam_user_two_active_access_key.py @@ -5,8 +5,8 @@ from prowler.providers.aws.services.iam.iam_client import iam_client class iam_user_two_active_access_key(Check): def execute(self) -> Check_Report_AWS: + findings = [] try: - findings = [] response = iam_client.credential_report for user in response: report = Check_Report_AWS(metadata=self.metadata(), resource=user) @@ -34,5 +34,4 @@ class iam_user_two_active_access_key(Check): findings.append(report) except Exception as error: logger.error(f"{error.__class__.__name__} -- {error}") - finally: - return findings + return findings diff --git a/prowler/providers/aws/services/iam/iam_user_with_temporary_credentials/iam_user_with_temporary_credentials.metadata.json b/prowler/providers/aws/services/iam/iam_user_with_temporary_credentials/iam_user_with_temporary_credentials.metadata.json index 9bf82258ab..e7fcf72387 100644 --- a/prowler/providers/aws/services/iam/iam_user_with_temporary_credentials/iam_user_with_temporary_credentials.metadata.json +++ b/prowler/providers/aws/services/iam/iam_user_with_temporary_credentials/iam_user_with_temporary_credentials.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "IAM user does not use long-lived credentials to access services other than IAM or STS", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "TTPs/Credential Access" ], "ServiceName": "iam", diff --git a/prowler/providers/aws/services/iam/lib/policy.py b/prowler/providers/aws/services/iam/lib/policy.py index 8442fefa66..d8806f280b 100644 --- a/prowler/providers/aws/services/iam/lib/policy.py +++ b/prowler/providers/aws/services/iam/lib/policy.py @@ -380,6 +380,56 @@ def is_condition_restricting_from_private_ip(condition_statement: dict) -> bool: return is_from_private_ip +def is_condition_restricting_to_trusted_ips( + condition_statement: dict, trusted_ips: list = None +) -> bool: + """Check if the policy condition restricts access to trusted IP addresses. + + Keyword arguments: + condition_statement -- The policy condition to check. For example: + { + "IpAddress": { + "aws:SourceIp": "X.X.X.X" + } + } + trusted_ips -- A list of trusted IP addresses or CIDR ranges. + """ + if not trusted_ips: + return False + + try: + CONDITION_OPERATOR = "IpAddress" + CONDITION_KEY = "aws:sourceip" + + if condition_statement.get(CONDITION_OPERATOR, {}): + condition_statement[CONDITION_OPERATOR] = { + k.lower(): v for k, v in condition_statement[CONDITION_OPERATOR].items() + } + + if condition_statement[CONDITION_OPERATOR].get(CONDITION_KEY, ""): + if not isinstance( + condition_statement[CONDITION_OPERATOR][CONDITION_KEY], list + ): + condition_statement[CONDITION_OPERATOR][CONDITION_KEY] = [ + condition_statement[CONDITION_OPERATOR][CONDITION_KEY] + ] + + trusted_ips_set = {ip.lower() for ip in trusted_ips} + for ip in condition_statement[CONDITION_OPERATOR][CONDITION_KEY]: + if ip == "*" or ip == "0.0.0.0/0": + return False + if ip not in trusted_ips_set: + return False + return True + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return False + + # TODO: Add logic for deny statements def is_policy_public( policy: dict, @@ -388,6 +438,7 @@ def is_policy_public( not_allowed_actions: list = [], check_cross_service_confused_deputy=False, trusted_account_ids: list = None, + trusted_ips: list = None, ) -> bool: """ Check if the policy allows public access to the resource. @@ -399,6 +450,7 @@ def is_policy_public( not_allowed_actions (list): List of actions that are not allowed, default: []. If not_allowed_actions is empty, the function will not consider the actions in the policy. check_cross_service_confused_deputy (bool): If the policy is checked for cross-service confused deputy, default: False trusted_account_ids (list): A list of trusted accound ids to reduce false positives on cross-account checks + trusted_ips (list): A list of trusted IP addresses or CIDR ranges to reduce false positives on IP-based checks Returns: bool: True if the policy allows public access, False otherwise """ @@ -511,6 +563,10 @@ def is_policy_public( and not is_condition_restricting_from_private_ip( statement.get("Condition", {}) ) + and not is_condition_restricting_to_trusted_ips( + statement.get("Condition", {}), + trusted_ips, + ) ) if is_public: break diff --git a/prowler/providers/aws/services/inspector2/inspector2_active_findings_exist/inspector2_active_findings_exist.metadata.json b/prowler/providers/aws/services/inspector2/inspector2_active_findings_exist/inspector2_active_findings_exist.metadata.json index 66785d6c32..c3efa80e97 100644 --- a/prowler/providers/aws/services/inspector2/inspector2_active_findings_exist/inspector2_active_findings_exist.metadata.json +++ b/prowler/providers/aws/services/inspector2/inspector2_active_findings_exist/inspector2_active_findings_exist.metadata.json @@ -9,7 +9,7 @@ "Software and Configuration Checks/Vulnerabilities/CVE", "Software and Configuration Checks/Patch Management", "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices" + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "inspector2", "SubServiceName": "", diff --git a/prowler/providers/aws/services/kafka/kafka_cluster_encryption_at_rest_uses_cmk/kafka_cluster_encryption_at_rest_uses_cmk.metadata.json b/prowler/providers/aws/services/kafka/kafka_cluster_encryption_at_rest_uses_cmk/kafka_cluster_encryption_at_rest_uses_cmk.metadata.json index 2d28190572..bc8313049e 100644 --- a/prowler/providers/aws/services/kafka/kafka_cluster_encryption_at_rest_uses_cmk/kafka_cluster_encryption_at_rest_uses_cmk.metadata.json +++ b/prowler/providers/aws/services/kafka/kafka_cluster_encryption_at_rest_uses_cmk/kafka_cluster_encryption_at_rest_uses_cmk.metadata.json @@ -4,9 +4,9 @@ "CheckTitle": "Kafka cluster has encryption at rest enabled with a customer managed key (CMK) or is serverless", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Data Encryption", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", - "Industry and Regulatory Standards/NIST 800-53 Controls (USA)", - "Industry and Regulatory Standards/PCI-DSS", + "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", "Effects/Data Exposure" ], "ServiceName": "kafka", diff --git a/prowler/providers/aws/services/kms/kms_key_not_publicly_accessible/kms_key_not_publicly_accessible.metadata.json b/prowler/providers/aws/services/kms/kms_key_not_publicly_accessible/kms_key_not_publicly_accessible.metadata.json index 2beb86eb11..5bbb4b0750 100644 --- a/prowler/providers/aws/services/kms/kms_key_not_publicly_accessible/kms_key_not_publicly_accessible.metadata.json +++ b/prowler/providers/aws/services/kms/kms_key_not_publicly_accessible/kms_key_not_publicly_accessible.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "Cloud KMS key does not grant access to allUsers or allAuthenticatedUsers", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "TTPs/Initial Access/Unauthorized Access", "Effects/Data Exposure" ], diff --git a/prowler/providers/aws/services/neptune/neptune_cluster_deletion_protection/neptune_cluster_deletion_protection.metadata.json b/prowler/providers/aws/services/neptune/neptune_cluster_deletion_protection/neptune_cluster_deletion_protection.metadata.json index dcfd7730b1..cce180b583 100644 --- a/prowler/providers/aws/services/neptune/neptune_cluster_deletion_protection/neptune_cluster_deletion_protection.metadata.json +++ b/prowler/providers/aws/services/neptune/neptune_cluster_deletion_protection/neptune_cluster_deletion_protection.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "Neptune cluster has deletion protection enabled", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "Effects/Data Destruction" ], "ServiceName": "neptune", diff --git a/prowler/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible.py b/prowler/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible.py index 19a890620b..d02ed12a5a 100644 --- a/prowler/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible.py +++ b/prowler/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible.py @@ -8,6 +8,7 @@ from prowler.providers.aws.services.opensearch.opensearch_client import ( class opensearch_service_domains_not_publicly_accessible(Check): def execute(self): findings = [] + trusted_ips = opensearch_client.audit_config.get("trusted_ips", []) for domain in opensearch_client.opensearch_domains.values(): report = Check_Report_AWS(metadata=self.metadata(), resource=domain) report.status = "PASS" @@ -18,7 +19,9 @@ class opensearch_service_domains_not_publicly_accessible(Check): if domain.vpc_id: report.status_extended = f"Opensearch domain {domain.name} is in a VPC, then it is not publicly accessible." elif domain.access_policy is not None and is_policy_public( - domain.access_policy, opensearch_client.audited_account + domain.access_policy, + opensearch_client.audited_account, + trusted_ips=trusted_ips, ): report.status = "FAIL" report.status_extended = f"Opensearch domain {domain.name} is publicly accessible via access policy." diff --git a/prowler/providers/aws/services/organizations/organizations_service.py b/prowler/providers/aws/services/organizations/organizations_service.py index 78f5b134bd..65b5dd8e94 100644 --- a/prowler/providers/aws/services/organizations/organizations_service.py +++ b/prowler/providers/aws/services/organizations/organizations_service.py @@ -80,10 +80,9 @@ class Organizations(AWSService): def _list_policies(self): logger.info("Organizations - List policies...") - + policies = {} try: list_policies_paginator = self.client.get_paginator("list_policies") - policies = {} for policy_type in AVAILABLE_ORGANIZATIONS_POLICIES: logger.info( "Organizations - List policies... - Type: %s", @@ -122,8 +121,7 @@ class Organizations(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return policies + return policies def _describe_policy(self, policy_id) -> dict: logger.info("Organizations - Describe policy: %s ...", policy_id) @@ -192,8 +190,7 @@ class Organizations(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return self.delegated_administrators + return self.delegated_administrators class Policy(BaseModel): diff --git a/prowler/providers/aws/services/rds/rds_cluster_multi_az/rds_cluster_multi_az.metadata.json b/prowler/providers/aws/services/rds/rds_cluster_multi_az/rds_cluster_multi_az.metadata.json index 78a4e0c0cd..c2d4c654c4 100644 --- a/prowler/providers/aws/services/rds/rds_cluster_multi_az/rds_cluster_multi_az.metadata.json +++ b/prowler/providers/aws/services/rds/rds_cluster_multi_az/rds_cluster_multi_az.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "RDS cluster has Multi-AZ enabled", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices" + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" ], "ServiceName": "rds", "SubServiceName": "", diff --git a/prowler/providers/aws/services/rds/rds_instance_inside_vpc/rds_instance_inside_vpc.metadata.json b/prowler/providers/aws/services/rds/rds_instance_inside_vpc/rds_instance_inside_vpc.metadata.json index c9f5fd2c96..1c33ebe34e 100644 --- a/prowler/providers/aws/services/rds/rds_instance_inside_vpc/rds_instance_inside_vpc.metadata.json +++ b/prowler/providers/aws/services/rds/rds_instance_inside_vpc/rds_instance_inside_vpc.metadata.json @@ -33,7 +33,8 @@ } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py index dca63d47ef..444f7dd2a2 100644 --- a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py +++ b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py @@ -12,19 +12,21 @@ class route53_dangling_ip_subdomain_takeover(Check): def execute(self) -> Check_Report_AWS: findings = [] + # When --region is used, Route53 service gathers EIPs from all regions + # to avoid false positives. Otherwise, use ec2_client data directly. + if route53_client.all_account_elastic_ips: + public_ips = list(route53_client.all_account_elastic_ips) + else: + public_ips = [eip.public_ip for eip in ec2_client.elastic_ips] + + # Add Network Interface public IPs from audited regions + for ni in ec2_client.network_interfaces.values(): + if ni.association and ni.association.get("PublicIp"): + public_ips.append(ni.association.get("PublicIp")) + for record_set in route53_client.record_sets: # Check only A records and avoid aliases (only need to check IPs not AWS Resources) if record_set.type == "A" and not record_set.is_alias: - # Gather Elastic IPs and Network Interfaces Public IPs inside the AWS Account - public_ips = [] - public_ips.extend([eip.public_ip for eip in ec2_client.elastic_ips]) - # Add public IPs from Network Interfaces - for network_interface in ec2_client.network_interfaces.values(): - if ( - network_interface.association - and network_interface.association.get("PublicIp") - ): - public_ips.append(network_interface.association.get("PublicIp")) for record in record_set.records: # Check if record is an IP Address if validate_ip_address(record): diff --git a/prowler/providers/aws/services/route53/route53_service.py b/prowler/providers/aws/services/route53/route53_service.py index 2e0eeb4499..bb579ca6ee 100644 --- a/prowler/providers/aws/services/route53/route53_service.py +++ b/prowler/providers/aws/services/route53/route53_service.py @@ -13,10 +13,20 @@ class Route53(AWSService): super().__init__(__class__.__name__, provider, global_service=True) self.hosted_zones = {} self.record_sets = [] + self.all_account_elastic_ips = [] self._list_hosted_zones() self._list_query_logging_configs() self._list_tags_for_resource() self._list_resource_record_sets() + # Gather Elastic IPs from all regions only when the --region flag is used, + # since EC2 service will only have EIPs from the specified region(s) but + # Route53 is global and can reference EIPs from any region. + if ( + "route53_dangling_ip_subdomain_takeover" + in provider.audit_metadata.expected_checks + and provider._identity.audited_regions + ): + self._get_all_region_elastic_ips() def _list_hosted_zones(self): logger.info("Route53 - Listing Hosting Zones...") @@ -77,6 +87,31 @@ class Route53(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_all_region_elastic_ips(self): + """Gather Elastic IPs from all enabled regions since Route53 is a global service. + + When running Prowler with --region, ec2_client.elastic_ips is scoped + to the specified region(s). Route53 records can reference EIPs from any + region, so we need to query all enabled regions to avoid false positives. + """ + logger.info("Route53 - Gathering Elastic IPs from all regions...") + all_regions = self.provider._enabled_regions or set( + self.provider._identity.audited_regions + ) + + for region in all_regions: + try: + regional_ec2_client = self.session.client("ec2", region_name=region) + for addr in regional_ec2_client.describe_addresses().get( + "Addresses", [] + ): + if "PublicIp" in addr: + self.all_account_elastic_ips.append(addr["PublicIp"]) + except Exception as error: + logger.warning( + f"{region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_query_logging_configs(self): logger.info("Route53 - Listing Query Logging Configs...") try: diff --git a/prowler/providers/aws/services/s3/s3_bucket_object_versioning/s3_bucket_object_versioning.metadata.json b/prowler/providers/aws/services/s3/s3_bucket_object_versioning/s3_bucket_object_versioning.metadata.json index 0b5b42e789..f80f847545 100644 --- a/prowler/providers/aws/services/s3/s3_bucket_object_versioning/s3_bucket_object_versioning.metadata.json +++ b/prowler/providers/aws/services/s3/s3_bucket_object_versioning/s3_bucket_object_versioning.metadata.json @@ -4,8 +4,8 @@ "CheckTitle": "S3 bucket has object versioning enabled", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", - "Industry and Regulatory Standards/CIS AWS Foundations Benchmark", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark", "Effects/Data Destruction" ], "ServiceName": "s3", diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_network_isolation_enabled/sagemaker_models_network_isolation_enabled.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_models_network_isolation_enabled/sagemaker_models_network_isolation_enabled.metadata.json index 662c26dc19..fc57b9243d 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_models_network_isolation_enabled/sagemaker_models_network_isolation_enabled.metadata.json +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_network_isolation_enabled/sagemaker_models_network_isolation_enabled.metadata.json @@ -34,7 +34,8 @@ "Categories": [ "trust-boundaries", "container-security", - "gen-ai" + "gen-ai", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_models_vpc_settings_configured/sagemaker_models_vpc_settings_configured.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_models_vpc_settings_configured/sagemaker_models_vpc_settings_configured.metadata.json index 355291570c..fc0eac0fc4 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_models_vpc_settings_configured/sagemaker_models_vpc_settings_configured.metadata.json +++ b/prowler/providers/aws/services/sagemaker/sagemaker_models_vpc_settings_configured/sagemaker_models_vpc_settings_configured.metadata.json @@ -31,8 +31,9 @@ } }, "Categories": [ - "trust-boundaries", - "gen-ai" + "trust-boundaries", + "gen-ai", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_intercontainer_encryption_enabled/sagemaker_training_jobs_intercontainer_encryption_enabled.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_intercontainer_encryption_enabled/sagemaker_training_jobs_intercontainer_encryption_enabled.metadata.json index db16d83a2b..5069c18fef 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_intercontainer_encryption_enabled/sagemaker_training_jobs_intercontainer_encryption_enabled.metadata.json +++ b/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_intercontainer_encryption_enabled/sagemaker_training_jobs_intercontainer_encryption_enabled.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "Amazon SageMaker training job has inter-container traffic encryption enabled", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Network Security", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "Effects/Data Exposure" ], "ServiceName": "sagemaker", diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_network_isolation_enabled/sagemaker_training_jobs_network_isolation_enabled.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_network_isolation_enabled/sagemaker_training_jobs_network_isolation_enabled.metadata.json index 097678546f..249baa841a 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_network_isolation_enabled/sagemaker_training_jobs_network_isolation_enabled.metadata.json +++ b/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_network_isolation_enabled/sagemaker_training_jobs_network_isolation_enabled.metadata.json @@ -33,7 +33,8 @@ }, "Categories": [ "trust-boundaries", - "gen-ai" + "gen-ai", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_vpc_settings_configured/sagemaker_training_jobs_vpc_settings_configured.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_vpc_settings_configured/sagemaker_training_jobs_vpc_settings_configured.metadata.json index ba6fa127f0..1e95e50167 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_vpc_settings_configured/sagemaker_training_jobs_vpc_settings_configured.metadata.json +++ b/prowler/providers/aws/services/sagemaker/sagemaker_training_jobs_vpc_settings_configured/sagemaker_training_jobs_vpc_settings_configured.metadata.json @@ -33,7 +33,8 @@ }, "Categories": [ "trust-boundaries", - "gen-ai" + "gen-ai", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/shield/shield_advanced_protection_in_associated_elastic_ips/shield_advanced_protection_in_associated_elastic_ips.metadata.json b/prowler/providers/aws/services/shield/shield_advanced_protection_in_associated_elastic_ips/shield_advanced_protection_in_associated_elastic_ips.metadata.json index 31720c530c..f3cfc6b991 100644 --- a/prowler/providers/aws/services/shield/shield_advanced_protection_in_associated_elastic_ips/shield_advanced_protection_in_associated_elastic_ips.metadata.json +++ b/prowler/providers/aws/services/shield/shield_advanced_protection_in_associated_elastic_ips/shield_advanced_protection_in_associated_elastic_ips.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "Elastic IP address is protected by AWS Shield Advanced", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices/Network Reachability", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "Effects/Denial of Service" ], "ServiceName": "shield", @@ -32,7 +32,8 @@ } }, "Categories": [ - "resilience" + "resilience", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/shield/shield_advanced_protection_in_classic_load_balancers/shield_advanced_protection_in_classic_load_balancers.metadata.json b/prowler/providers/aws/services/shield/shield_advanced_protection_in_classic_load_balancers/shield_advanced_protection_in_classic_load_balancers.metadata.json index 2f60ba8226..87e5c39234 100644 --- a/prowler/providers/aws/services/shield/shield_advanced_protection_in_classic_load_balancers/shield_advanced_protection_in_classic_load_balancers.metadata.json +++ b/prowler/providers/aws/services/shield/shield_advanced_protection_in_classic_load_balancers/shield_advanced_protection_in_classic_load_balancers.metadata.json @@ -31,7 +31,8 @@ } }, "Categories": [ - "resilience" + "resilience", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/shield/shield_advanced_protection_in_cloudfront_distributions/shield_advanced_protection_in_cloudfront_distributions.metadata.json b/prowler/providers/aws/services/shield/shield_advanced_protection_in_cloudfront_distributions/shield_advanced_protection_in_cloudfront_distributions.metadata.json index 1a7d6996c3..d9c6fdbf85 100644 --- a/prowler/providers/aws/services/shield/shield_advanced_protection_in_cloudfront_distributions/shield_advanced_protection_in_cloudfront_distributions.metadata.json +++ b/prowler/providers/aws/services/shield/shield_advanced_protection_in_cloudfront_distributions/shield_advanced_protection_in_cloudfront_distributions.metadata.json @@ -31,7 +31,8 @@ } }, "Categories": [ - "resilience" + "resilience", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/sqs/sqs_queues_server_side_encryption_enabled/sqs_queues_server_side_encryption_enabled.metadata.json b/prowler/providers/aws/services/sqs/sqs_queues_server_side_encryption_enabled/sqs_queues_server_side_encryption_enabled.metadata.json index 4e6d7b833b..46d7fd2109 100644 --- a/prowler/providers/aws/services/sqs/sqs_queues_server_side_encryption_enabled/sqs_queues_server_side_encryption_enabled.metadata.json +++ b/prowler/providers/aws/services/sqs/sqs_queues_server_side_encryption_enabled/sqs_queues_server_side_encryption_enabled.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "SQS queue has server-side encryption enabled", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "Effects/Data Exposure" ], "ServiceName": "sqs", diff --git a/prowler/providers/aws/services/vpc/vpc_endpoint_connections_trust_boundaries/vpc_endpoint_connections_trust_boundaries.metadata.json b/prowler/providers/aws/services/vpc/vpc_endpoint_connections_trust_boundaries/vpc_endpoint_connections_trust_boundaries.metadata.json index 9460f24bc1..5e76f071fa 100644 --- a/prowler/providers/aws/services/vpc/vpc_endpoint_connections_trust_boundaries/vpc_endpoint_connections_trust_boundaries.metadata.json +++ b/prowler/providers/aws/services/vpc/vpc_endpoint_connections_trust_boundaries/vpc_endpoint_connections_trust_boundaries.metadata.json @@ -4,7 +4,7 @@ "CheckTitle": "VPC endpoint policy allows access only from trusted AWS accounts", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", - "Industry and Regulatory Standards/AWS Foundational Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", "TTPs/Initial Access" ], "ServiceName": "vpc", diff --git a/prowler/providers/aws/services/vpc/vpc_peering_routing_tables_with_least_privilege/vpc_peering_routing_tables_with_least_privilege.metadata.json b/prowler/providers/aws/services/vpc/vpc_peering_routing_tables_with_least_privilege/vpc_peering_routing_tables_with_least_privilege.metadata.json index 2e6891f377..442f60cb7e 100644 --- a/prowler/providers/aws/services/vpc/vpc_peering_routing_tables_with_least_privilege/vpc_peering_routing_tables_with_least_privilege.metadata.json +++ b/prowler/providers/aws/services/vpc/vpc_peering_routing_tables_with_least_privilege/vpc_peering_routing_tables_with_least_privilege.metadata.json @@ -34,7 +34,8 @@ } }, "Categories": [ - "trust-boundaries" + "trust-boundaries", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/config.py b/prowler/providers/azure/config.py index c9c9f4823a..e1aa257a97 100644 --- a/prowler/providers/azure/config.py +++ b/prowler/providers/azure/config.py @@ -1,7 +1,10 @@ from uuid import UUID -# Service management API +# Conditional Access target resource identifiers +# (Graph API includeApplications values) WINDOWS_AZURE_SERVICE_MANAGEMENT_API = "797f4846-ba00-4fd7-ba43-dac1f8f63013" +# Named app group — Graph API returns this literal string, not a GUID +MICROSOFT_ADMIN_PORTALS = "MicrosoftAdminPortals" # Authorization policy roles GUEST_USER_ACCESS_NO_RESTRICTICTED = UUID("a0b1b346-4d3e-4e8b-98f8-753987be4970") diff --git a/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/__init__.py b/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals.metadata.json b/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals.metadata.json new file mode 100644 index 0000000000..d12df2e5e6 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "entra_conditional_access_policy_require_mfa_for_admin_portals", + "CheckTitle": "Conditional Access policy requires MFA for Microsoft Admin Portals", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra Conditional Access** is evaluated for a policy that requires **multifactor authentication** when accessing **Microsoft Admin Portals** (Microsoft 365 Admin Center, Microsoft Entra Admin Center, Microsoft Exchange Admin Center, etc.). The check confirms an enabled policy targets **All users**, includes the Microsoft Admin Portals app, and enforces an **MFA** grant control.", + "Risk": "Without **MFA** on admin portals, attackers with stolen credentials can access **Microsoft 365 Admin Center**, **Entra Admin Center**, or **Exchange Admin Center** to modify tenant settings, escalate privileges, and exfiltrate data. This directly impacts **confidentiality**, **integrity**, and **availability** of all services managed through those portals.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/how-to-policy-mfa-admin-portals", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Protection > Conditional Access > Policies\n3. Click + New policy and enter a name\n4. Under Users > Include, select All users\n5. Under Exclude, check Users and groups and select break-glass / non-interactive service accounts\n6. Under Target resources > Include, click Select apps, then select Microsoft Admin Portals\n7. Under Grant, select Grant access and check Require multifactor authentication\n8. Set Enable policy to Report-only, click Create\n9. After testing, change Enable policy from Report-only to On", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\"\n\n conditions {\n users {\n included_users = [\"All\"]\n excluded_users = [\"\"]\n }\n applications {\n included_applications = [\"MicrosoftAdminPortals\"] # Critical: Microsoft Admin Portals\n }\n }\n\n grant_controls {\n operator = \"OR\"\n built_in_controls = [\"mfa\"] # Critical: requires MFA\n }\n}\n```" + }, + "Recommendation": { + "Text": "Create a **Conditional Access policy** that requires **MFA** for the **Microsoft Admin Portals** app targeting **All users**. Exclude only **break-glass** emergency accounts and non-interactive service principals. Test in **Report-only** mode before enforcing. Prefer **phishing-resistant** MFA methods (FIDO2, passkeys) and apply **least privilege** principles.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_require_mfa_for_admin_portals" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Conditional Access policies require Microsoft Entra ID P1 or P2 licenses. Similarly, they may require additional overhead to maintain if users lose access to their MFA. Any users or groups which are granted an exception to this policy should be carefully tracked, be granted only minimal necessary privileges, and conditional access exceptions should be regularly reviewed or investigated." +} diff --git a/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals.py b/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals.py new file mode 100644 index 0000000000..30ba990ec6 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_admin_portals/entra_conditional_access_policy_require_mfa_for_admin_portals.py @@ -0,0 +1,44 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.config import MICROSOFT_ADMIN_PORTALS +from prowler.providers.azure.services.entra.entra_client import entra_client + + +class entra_conditional_access_policy_require_mfa_for_admin_portals(Check): + def execute(self) -> Check_Report_Azure: + findings = [] + + for ( + tenant_name, + conditional_access_policies, + ) in entra_client.conditional_access_policy.items(): + for policy in conditional_access_policies.values(): + if ( + policy.state == "enabled" + and "All" in policy.users["include"] + and MICROSOFT_ADMIN_PORTALS in policy.target_resources["include"] + and any( + "mfa" in access_control.lower() + for access_control in policy.access_controls["grant"] + ) + ): + report = Check_Report_Azure( + metadata=self.metadata(), resource=policy + ) + report.subscription = f"Tenant: {tenant_name}" + report.status = "PASS" + report.status_extended = "Conditional Access Policy requires MFA for Microsoft Admin Portals." + break + else: + report = Check_Report_Azure( + metadata=self.metadata(), + resource=conditional_access_policies, + ) + report.subscription = f"Tenant: {tenant_name}" + report.resource_name = "Conditional Access Policy" + report.resource_id = "Conditional Access Policy" + report.status = "FAIL" + report.status_extended = "Conditional Access Policy does not require MFA for Microsoft Admin Portals." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api.metadata.json b/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api.metadata.json index 89981231ad..6304f8676b 100644 --- a/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api.metadata.json +++ b/prowler/providers/azure/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api.metadata.json @@ -1,30 +1,36 @@ { "Provider": "azure", "CheckID": "entra_conditional_access_policy_require_mfa_for_management_api", - "CheckTitle": "Ensure Multifactor Authentication is Required for Windows Azure Service Management API", + "CheckTitle": "Multifactor Authentication is required for Windows Azure Service Management API", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "#microsoft.graph.conditionalAccess", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "This recommendation ensures that users accessing the Windows Azure Service Management API (i.e. Azure Powershell, Azure CLI, Azure Resource Manager API, etc.) are required to use multifactor authentication (MFA) credentials when accessing resources through the Windows Azure Service Management API.", - "Risk": "Administrative access to the Windows Azure Service Management API should be secured with a higher level of scrutiny to authenticating mechanisms. Enabling multifactor authentication is recommended to reduce the potential for abuse of Administrative actions, and to prevent intruders or compromised admin credentials from changing administrative settings.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-azure-management", + "Description": "**Microsoft Entra Conditional Access** is evaluated for a policy that requires **multifactor authentication** when accessing the **Windows Azure Service Management API** (Azure PowerShell, Azure CLI, Azure Resource Manager API, etc.). The check confirms an enabled policy targets **All users**, includes the Service Management API app, and enforces an **MFA** grant control.", + "Risk": "Without **MFA** on the Service Management API, attackers with stolen credentials can use **Azure CLI**, **PowerShell**, or the **Resource Manager API** to modify infrastructure, escalate privileges, and exfiltrate data. This directly impacts **confidentiality**, **integrity**, and **availability** of all Azure resources managed through the API.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-azure-management", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Protection > Conditional Access > Policies\n3. Click + New policy and enter a name\n4. Under Users > Include, select All users\n5. Under Exclude, check Users and groups and select break-glass / non-interactive service accounts\n6. Under Target resources > Include, click Select apps, then select Windows Azure Service Management API\n7. Under Grant, select Grant access and check Require multifactor authentication\n8. Set Enable policy to Report-only, click Create\n9. After testing, change Enable policy from Report-only to On", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\"\n\n conditions {\n users {\n included_users = [\"All\"]\n excluded_users = [\"\"]\n }\n applications {\n included_applications = [\"797f4846-ba00-4fd7-ba43-dac1f8f63013\"] # Critical: Windows Azure Service Management API\n }\n }\n\n grant_controls {\n operator = \"OR\"\n built_in_controls = [\"mfa\"] # Critical: requires MFA\n }\n}\n```" }, "Recommendation": { "Text": "1. From the Azure Admin Portal dashboard, open Microsoft Entra ID. 2. Click Security in the Entra ID blade. 3. Click Conditional Access in the Security blade. 4. Click Policies in the Conditional Access blade. 5. Click + New policy. 6. Enter a name for the policy. 7. Click the blue text under Users. 8. Under Include, select All users. 9. Under Exclude, check Users and groups. 10. Select users or groups to be exempted from this policy (e.g. break-glass emergency accounts, and non-interactive service accounts) then click the Select button. 11. Click the blue text under Target Resources. 12. Under Include, click the Select apps radio button. 13. Click the blue text under Select. 14. Check the box next to Windows Azure Service Management APIs then click the Select button. 15. Click the blue text under Grant. 16. Under Grant access check the box for Require multifactor authentication then click the Select button. 17. Before creating, set Enable policy to Report-only. 18. Click Create. After testing the policy in report-only mode, update the Enable policy setting from Report-only to On.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps" + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_require_mfa_for_management_api" } }, - "Categories": [], + "Categories": [ + "e3" + ], "DependsOn": [], "RelatedTo": [], "Notes": "Conditional Access policies require Microsoft Entra ID P1 or P2 licenses. Similarly, they may require additional overhead to maintain if users lose access to their MFA. Any users or groups which are granted an exception to this policy should be carefully tracked, be granted only minimal necessary privileges, and conditional access exceptions should be regularly reviewed or investigated." diff --git a/prowler/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users.metadata.json b/prowler/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users.metadata.json index 8c69462bac..6f05727fc3 100644 --- a/prowler/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users.metadata.json +++ b/prowler/providers/azure/services/entra/entra_global_admin_in_less_than_five_users/entra_global_admin_in_less_than_five_users.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra Global Administrator** assignments are evaluated by counting current role members per tenant and identifying when the number of assignees is `5` or more.", "Risk": "Having **5+ Global Administrators** expands the privileged attack surface. Compromised credentials or tokens can enable tenant-wide changes, disable security controls, exfiltrate data, and create persistence, impacting **confidentiality**, **integrity**, and **availability** across Entra, Microsoft 365, and Azure.", @@ -29,7 +29,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.metadata.json b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.metadata.json index e6bd208eaf..2cdd6bdc9c 100644 --- a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.metadata.json +++ b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra** non-privileged users are assessed for **multifactor authentication** by verifying they have **two or more registered authentication methods** (*MFA enrollment*).", "Risk": "Absent **MFA** on standard accounts enables password-only logins after phishing, reuse, or spraying, leading to **account takeover**. Attackers can access email, files, and apps, send internal phishing, and escalate, undermining **confidentiality** and **integrity**, and risking **availability** via malicious changes.", @@ -30,7 +30,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py index c86fc02da7..d231a7a6b1 100644 --- a/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_non_privileged_user_has_mfa/entra_non_privileged_user_has_mfa.py @@ -11,7 +11,7 @@ class entra_non_privileged_user_has_mfa(Check): for tenant_domain, users in entra_client.users.items(): for user in users.values(): - if not is_privileged_user( + if user.account_enabled and not is_privileged_user( user, entra_client.directory_roles[tenant_domain] ): report = Check_Report_Azure(metadata=self.metadata(), resource=user) diff --git a/prowler/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups.metadata.json b/prowler/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups.metadata.json index a491370fab..55aaaceb77 100644 --- a/prowler/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups.metadata.json +++ b/prowler/providers/azure/services/entra/entra_policy_default_users_cannot_create_security_groups/entra_policy_default_users_cannot_create_security_groups.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra authorization policy** setting for default user role permissions governing creation of **security groups** by non-privileged users.\n\nThe value of `allowed_to_create_security_groups` is examined to ensure group creation is limited to administrators across portals, API, and PowerShell.", "Risk": "Allowing standard users to create security groups drives **entitlement sprawl** and can grant **unauthorized access** when those groups are tied to apps, sites, or roles. This weakens **least privilege**, complicates audits, and enables **lateral movement** or data exfiltration via misassigned group-based permissions.", @@ -29,7 +29,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps.metadata.json b/prowler/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps.metadata.json index 1c5e2c46fb..f7d9826626 100644 --- a/prowler/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps.metadata.json +++ b/prowler/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_apps/entra_policy_ensure_default_user_cannot_create_apps.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra authorization policy** controls whether default users can create application registrations via `allowed_to_create_apps`. App creation is expected to be limited to administrators or explicitly delegated roles.", "Risk": "Permitting default users to register apps enables **unvetted service principals**, **consent phishing**, and **over-privileged API access**, threatening data **confidentiality** and **integrity**. Adversaries can persist with app credentials, exfiltrate mail/files, and perform **lateral movement** using rogue permissions.", @@ -30,7 +30,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants.metadata.json b/prowler/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants.metadata.json index 139c6056e5..7adc58d201 100644 --- a/prowler/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants.metadata.json +++ b/prowler/providers/azure/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra authorization policy** governs whether default users can create new tenants. This evaluates if tenant creation is disabled for non-admin users via `allowed_to_create_tenants=false`.", "Risk": "Permitting default users to create tenants fuels **shadow IT** and identity sprawl. Creators become **Global Administrators** of unmanaged tenants, eroding **confidentiality** and **integrity** through unsanctioned apps and unmonitored data flows, and degrading **availability** of centralized governance.", @@ -30,7 +30,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles.metadata.json b/prowler/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles.metadata.json index 5925a43184..d0710bb6c3 100644 --- a/prowler/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles.metadata.json +++ b/prowler/providers/azure/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra authorization policy** controls who can send **B2B guest invitations**.\n\nSecure posture is when invitations are restricted to specific admin roles (`adminsAndGuestInviters`) or completely disabled (`none`).", "Risk": "**Open guest invitation** rights let members or guests add external users without oversight, expanding the attack surface.\n\nImpacts:\n- **Confidentiality**: data leakage via overshared resources\n- **Integrity**: privilege escalation through group/team access\n- **Availability**: difficult containment due to account sprawl", @@ -29,7 +29,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions.metadata.json b/prowler/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions.metadata.json index eadeb50deb..40e29792f8 100644 --- a/prowler/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions.metadata.json +++ b/prowler/providers/azure/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra authorization policy** guest settings are assessed to determine whether guest user access is limited to the properties and memberships of their own directory objects (`Restricted access`) instead of broader visibility into users and groups", "Risk": "Excess guest visibility enables **directory reconnaissance**, exposing user and group details for **phishing**, **password spraying**, and targeted attacks. This weakens **confidentiality** and can facilitate **privilege escalation** and lateral movement through informed abuse of group memberships and access paths.", @@ -29,7 +29,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps.metadata.json b/prowler/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps.metadata.json index c01f007d4f..7b09eb8996 100644 --- a/prowler/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps.metadata.json +++ b/prowler/providers/azure/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "Microsoft Entra authorization settings are evaluated to determine if the default user role permits **user consent to applications**. The check looks at permission grant policies to see whether end users can authorize apps to access organization data on their behalf, or if consent is restricted (e.g., `Do not allow user consent`).", "Risk": "Permitting end-user consent enables **consent phishing** and over-privileged OAuth grants. Attackers can obtain tokens to read/send mail, access files, or act as the user, causing **data exfiltration**, persistence beyond password resets/MFA changes, and abuse of connected apps, impacting confidentiality and integrity.", @@ -31,7 +31,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps.metadata.json b/prowler/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps.metadata.json index 65e41bcea4..8aaeb21ad6 100644 --- a/prowler/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps.metadata.json +++ b/prowler/providers/azure/services/entra/entra_policy_user_consent_for_verified_apps/entra_policy_user_consent_for_verified_apps.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra** authorization policy for the default user role is assessed for assignment of the user-consent policy `microsoft-user-default-legacy`. Its presence means users can self-consent to app permissions; its absence indicates consent is restricted (e.g., only verified publishers or low-impact scopes).", "Risk": "Broad self-consent enables **OAuth consent phishing** and rogue apps to gain tokens to tenant data (**confidentiality**), request write scopes to change resources (**integrity**), and persist via refresh tokens after password changes. Mis-scoped grants can drive lateral movement and privilege escalation.", @@ -29,7 +29,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.metadata.json b/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.metadata.json index 97ddb4252f..5b622417b6 100644 --- a/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.metadata.json +++ b/prowler/providers/azure/services/entra/entra_privileged_user_has_mfa/entra_privileged_user_has_mfa.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra** privileged accounts are expected to use **multifactor authentication**. This evaluates users assigned to elevated directory roles and confirms they have **multiple authentication methods** registered for sign-in.", "Risk": "Without **MFA**, privileged accounts face **phishing**, **password spraying**, and **credential reuse** risks. Compromise can grant tenant-wide admin control to alter roles, create backdoors, exfiltrate data, and weaken defenses, impacting **confidentiality**, **integrity**, and **availability**.", @@ -30,7 +30,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled.metadata.json b/prowler/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled.metadata.json index c3e9511afb..1efcb6103b 100644 --- a/prowler/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled.metadata.json +++ b/prowler/providers/azure/services/entra/entra_security_defaults_enabled/entra_security_defaults_enabled.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "security", "Description": "Microsoft Entra **Security defaults** provide tenant-wide baseline identity protections:\n- MFA registration and challenges\n- Legacy auth (`IMAP/POP/SMTP`) blocked\n- Extra checks for privileged access\n\nThis evaluation identifies whether that baseline is enabled at the tenant level.", "Risk": "Absent these defaults, users can sign in with **password-only** or via **legacy protocols** that bypass MFA, enabling **password spray**, replay, and phishing-based takeovers. Compromise risks data exposure (confidentiality), unauthorized changes (integrity), and service disruption (availability).", @@ -29,7 +29,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index 011ea675b0..eb1d62ac11 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -3,7 +3,9 @@ from asyncio import gather from typing import List, Optional from uuid import UUID +from kiota_abstractions.base_request_configuration import RequestConfiguration from msgraph import GraphServiceClient +from msgraph.generated.users.users_request_builder import UsersRequestBuilder from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -65,9 +67,16 @@ class Entra(AzureService): logger.info("Entra - Getting users...") users = {} try: + request_configuration = RequestConfiguration( + query_parameters=UsersRequestBuilder.UsersRequestBuilderGetQueryParameters( + select=["id", "displayName", "accountEnabled"] + ) + ) for tenant, client in self.clients.items(): users.update({tenant: {}}) - users_response = await client.users.get() + users_response = await client.users.get( + request_configuration=request_configuration + ) registration_details = await self._get_user_registration_details(client) try: @@ -81,6 +90,9 @@ class Entra(AzureService): is_mfa_capable=registration_details.get( user.id, False ), + account_enabled=getattr( + user, "account_enabled", True + ), ) } ) @@ -409,6 +421,7 @@ class User(BaseModel): id: str name: str is_mfa_capable: bool = False + account_enabled: bool = True class DefaultUserRolePermissions(BaseModel): diff --git a/prowler/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists.metadata.json b/prowler/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists.metadata.json index 95f6a5fc7d..2676fe30f6 100644 --- a/prowler/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists.metadata.json +++ b/prowler/providers/azure/services/entra/entra_trusted_named_locations_exists/entra_trusted_named_locations_exists.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "network", "Description": "**Microsoft Entra ID Conditional Access** supports **trusted named locations** defined by **public IP ranges**. Presence of at least one location marked `trusted` with IP CIDR ranges available for use in policy conditions.", "Risk": "Without trusted IP-based locations, policies can't reliably distinguish corporate networks from unknown sources. This weakens **confidentiality and integrity**, enabling risky sign-ins to avoid stricter controls and forcing coarse rules that over-prompt users or leave **account takeover** and **data exfiltration** paths open.", @@ -30,7 +30,8 @@ }, "Categories": [ "identity-access", - "trust-boundaries" + "trust-boundaries", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.metadata.json b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.metadata.json index 5a436eb7d4..9917cde5fc 100644 --- a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.metadata.json +++ b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra** users with Azure roles that grant VM sign-in or management access-such as `Owner`, `Contributor`, `Virtual Machine * Login`, and `Virtual Machine Contributor`-are evaluated for **multi-factor authentication** enrollment. The finding highlights accounts with VM access that lack more than one authentication factor.", "Risk": "Without **MFA**, accounts with VM access are vulnerable to phishing, password spraying, and credential stuffing. Compromise can enable remote VM login, abuse of the VM's managed identity, privilege escalation, and lateral movement-impacting confidentiality, integrity, and availability of workloads.", @@ -30,7 +30,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups.metadata.json b/prowler/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups.metadata.json index f11b93c9d3..8fcb6a8261 100644 --- a/prowler/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups.metadata.json +++ b/prowler/providers/azure/services/entra/entra_users_cannot_create_microsoft_365_groups/entra_users_cannot_create_microsoft_365_groups.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.aadiam/tenants", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", "Description": "**Microsoft Entra** directory setting **Group.Unified** governs who can create **Microsoft 365 Groups**. The evaluation inspects `EnableGroupCreation` and, when present, `GroupCreationAllowedGroupId` to determine if group creation is broadly allowed or restricted to a designated group.", "Risk": "Unrestricted group creation drives sprawl of Teams, SharePoint sites, and mailboxes, undermining **confidentiality** via public spaces and guest invites. Compromised accounts can create groups to stage exfiltration or impersonation. It also heightens **integrity** risks from unsanctioned owners and **operational** burden for lifecycle and governance.", @@ -30,7 +30,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.metadata.json index a82171835f..fbb45216fb 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.metadata.json @@ -1,15 +1,15 @@ { "Provider": "azure", "CheckID": "keyvault_key_expiration_set_in_non_rbac", - "CheckTitle": "Key Vault without RBAC authorization has expiration date set for all enabled keys", + "CheckTitle": "Key in non-RBAC Key Vault has expiration date set", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/keys", "ResourceGroup": "security", - "Description": "**Azure Key Vaults** using access **policies (non-RBAC)** are assessed to confirm all **enabled keys** have an `expiration` (`exp`) defined. The finding highlights keys in these vaults that lack a set lifetime.", + "Description": "Each **enabled key** in an **Azure Key Vault** using **access policies (non-RBAC)** is assessed to confirm it has an `expiration` (`exp`) attribute defined.", "Risk": "Non-expiring keys enable indefinite use, degrading **confidentiality** and **integrity**. Stale or compromised keys can decrypt data, forge signatures, and maintain persistence. Absent lifetimes weaken rotation discipline and impede timely revocation, increasing exposure to cryptographic and operational drift.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.py b/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.py index debca1ac34..6b3e8803c4 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.py +++ b/prowler/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac.py @@ -7,20 +7,19 @@ class keyvault_key_expiration_set_in_non_rbac(Check): findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): for keyvault in key_vaults: - if not keyvault.properties.enable_rbac_authorization and keyvault.keys: - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) - report.subscription = subscription - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has all the keys with expiration date set." - has_key_without_expiration = False - for key in keyvault.keys: - if not key.attributes.expires and key.enabled: + if not keyvault.properties.enable_rbac_authorization: + for key in keyvault.keys or []: + if not key.enabled: + continue + report = Check_Report_Azure( + metadata=self.metadata(), resource=key + ) + report.subscription = subscription + if not key.attributes.expires: report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the key {key.name} without expiration date set." - has_key_without_expiration = True - findings.append(report) - if not has_key_without_expiration: + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription} does not have an expiration date set." + else: + report.status = "PASS" + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription} has an expiration date set." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.metadata.json index 45c459c948..b42c6d82b7 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/keys", "ResourceGroup": "security", - "Description": "**Azure Key Vault** keys configured with a **rotation policy** that includes a `Rotate` lifetime action.\n\nThe evaluation looks for lifetime actions that schedule automatic key version creation; keys without this policy are not configured for auto-rotation.", + "Description": "Each **Azure Key Vault** key is assessed for a **rotation policy** that includes a `Rotate` lifetime action scheduling automatic key version creation.", "Risk": "Without **auto-rotation**, keys may outlive policy, increasing exposure if material is leaked and weakening **confidentiality**.\n\nExpired keys without planned rollover can break decrypt/unwrap operations, impacting **availability**. Long-lived keys hinder incident response and enable prolonged misuse of stale versions.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.py b/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.py index 088a02d827..988bd5c47c 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.py +++ b/prowler/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled.py @@ -7,23 +7,21 @@ class keyvault_key_rotation_enabled(Check): findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): for keyvault in key_vaults: - if keyvault.keys: - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) + for key in keyvault.keys or []: + report = Check_Report_Azure(metadata=self.metadata(), resource=key) report.subscription = subscription - for key in keyvault.keys: - if ( - key.rotation_policy - and key.rotation_policy.lifetime_actions - and key.rotation_policy.lifetime_actions[0].action - == "Rotate" - ): - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the key {key.name} with rotation policy set." - else: - report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the key {key.name} without rotation policy set." - - findings.append(report) + if ( + key.rotation_policy + and key.rotation_policy.lifetime_actions + and any( + action.action == "Rotate" + for action in key.rotation_policy.lifetime_actions + ) + ): + report.status = "PASS" + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription} has a rotation policy set." + else: + report.status = "FAIL" + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription} does not have a rotation policy set." + findings.append(report) return findings 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 b093492c7e..164a43b5da 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 @@ -1,7 +1,7 @@ { "Provider": "azure", "CheckID": "keyvault_logging_enabled", - "CheckTitle": "Key Vault has a diagnostic setting capturing audit logs", + "CheckTitle": "Key Vault has at least one diagnostic setting with audit logging enabled", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", 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 2aff55c77d..dee956d63d 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 @@ -10,29 +10,20 @@ class keyvault_logging_enabled(Check): for keyvault in key_vaults: report = Check_Report_Azure(metadata=self.metadata(), resource=keyvault) report.subscription = subscription_name - if not keyvault.monitor_diagnostic_settings: - report.status = "FAIL" - report.status_extended = f"There are no diagnostic settings capturing audit logs for Key Vault {keyvault.name} in subscription {subscription_name}." - findings.append(report) - else: - for diagnostic_setting in keyvault.monitor_diagnostic_settings: - report.resource_name = diagnostic_setting.name - report.resource_id = diagnostic_setting.id - report.location = keyvault.location - report.status = "FAIL" - report.status_extended = f"Diagnostic setting {diagnostic_setting.name} for Key Vault {keyvault.name} in subscription {subscription_name} does not have audit logging." - audit = False - allLogs = False - for log in diagnostic_setting.logs: - if log.category_group == "audit" and log.enabled: - audit = True - if log.category_group == "allLogs" and log.enabled: - allLogs = True - if audit and allLogs: - report.status = "PASS" - report.status_extended = f"Diagnostic setting {diagnostic_setting.name} for Key Vault {keyvault.name} in subscription {subscription_name} has audit logging." - break - - findings.append(report) + report.status = "FAIL" + report.status_extended = f"Key Vault {keyvault.name} in subscription {subscription_name} does not have a diagnostic setting with audit logging." + for diagnostic_setting in keyvault.monitor_diagnostic_settings or []: + has_audit = False + has_all_logs = False + for log in diagnostic_setting.logs: + if log.category_group == "audit" and log.enabled: + has_audit = True + if log.category_group == "allLogs" and log.enabled: + has_all_logs = True + if has_audit and has_all_logs: + report.status = "PASS" + report.status_extended = f"Key Vault {keyvault.name} in subscription {subscription_name} has a diagnostic setting with audit logging." + break + findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.metadata.json index edfb367858..38ca08f08d 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.metadata.json @@ -1,15 +1,15 @@ { "Provider": "azure", "CheckID": "keyvault_non_rbac_secret_expiration_set", - "CheckTitle": "Non-RBAC Key Vault has expiration date set for all secrets", + "CheckTitle": "Secret in non-RBAC Key Vault has expiration date set", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/secrets", "ResourceGroup": "security", - "Description": "**Azure Key Vault (non-RBAC)** secrets are expected to have an **explicit expiration date**.\n\nThis examines each **enabled secret** to confirm the `expires` attribute is defined.", + "Description": "Each **enabled secret** in an **Azure Key Vault (non-RBAC)** is assessed to confirm it has an **explicit expiration date** (`expires` attribute) defined.", "Risk": "Secrets without expiration persist indefinitely, widening the window for misuse.\n\nIf leaked or forgotten, they allow long-term, covert access to services and data, undermining **confidentiality** and **integrity**, and complicating incident response and revocation.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.py b/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.py index 9afe72fb21..2123ad0d32 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.py +++ b/prowler/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set.py @@ -7,23 +7,19 @@ class keyvault_non_rbac_secret_expiration_set(Check): findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): for keyvault in key_vaults: - if ( - not keyvault.properties.enable_rbac_authorization - and keyvault.secrets - ): - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) - report.subscription = subscription - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has all the secrets with expiration date set." - has_secret_without_expiration = False - for secret in keyvault.secrets: - if not secret.attributes.expires and secret.enabled: + if not keyvault.properties.enable_rbac_authorization: + for secret in keyvault.secrets or []: + if not secret.enabled: + continue + report = Check_Report_Azure( + metadata=self.metadata(), resource=secret + ) + report.subscription = subscription + if not secret.attributes.expires: report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the secret {secret.name} without expiration date set." - has_secret_without_expiration = True - findings.append(report) - if not has_secret_without_expiration: + report.status_extended = f"Secret {secret.name} in Key Vault {keyvault.name} from subscription {subscription} does not have an expiration date set." + else: + report.status = "PASS" + report.status_extended = f"Secret {secret.name} in Key Vault {keyvault.name} from subscription {subscription} has an expiration date set." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.metadata.json index 531840721b..8b6c1d336f 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.metadata.json @@ -1,15 +1,15 @@ { "Provider": "azure", "CheckID": "keyvault_rbac_key_expiration_set", - "CheckTitle": "RBAC-enabled Key Vault has expiration date set for all keys", + "CheckTitle": "Key in RBAC-enabled Key Vault has expiration date set", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/keys", "ResourceGroup": "security", - "Description": "**Azure Key Vaults** with **RBAC-enabled access control** are evaluated to confirm every **enabled key** defines an **expiration** (`exp`). Any key lacking this attribute is identified.", + "Description": "Each **enabled key** in an **Azure Key Vault** with **RBAC-enabled access control** is assessed to confirm it has an **expiration** (`exp`) attribute defined.", "Risk": "**Keys without expiration** can remain active indefinitely.\nIf exposed, attackers can decrypt data, forge signatures (code/tokens), and maintain persistence, undermining **confidentiality** and **integrity**. Absent end-of-life also weakens rotation discipline and crypto agility.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.py b/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.py index 1ca90c445b..1a5ca4f268 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.py +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set.py @@ -7,20 +7,19 @@ class keyvault_rbac_key_expiration_set(Check): findings = [] for subscription, key_vaults in keyvault_client.key_vaults.items(): for keyvault in key_vaults: - if keyvault.properties.enable_rbac_authorization and keyvault.keys: - report = Check_Report_Azure( - metadata=self.metadata(), resource=keyvault - ) - report.subscription = subscription - report.status = "PASS" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has all the keys with expiration date set." - has_key_without_expiration = False - for key in keyvault.keys: - if not key.attributes.expires and key.enabled: + if keyvault.properties.enable_rbac_authorization: + for key in keyvault.keys or []: + if not key.enabled: + continue + report = Check_Report_Azure( + metadata=self.metadata(), resource=key + ) + report.subscription = subscription + if not key.attributes.expires: report.status = "FAIL" - report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has the key {key.name} without expiration date set." - has_key_without_expiration = True - findings.append(report) - if not has_key_without_expiration: + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription} does not have an expiration date set." + else: + report.status = "PASS" + report.status_extended = f"Key {key.name} in Key Vault {keyvault.name} from subscription {subscription} has an expiration date set." findings.append(report) return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.metadata.json index 681668cb63..2cab692a98 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.metadata.json +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.metadata.json @@ -1,15 +1,15 @@ { "Provider": "azure", "CheckID": "keyvault_rbac_secret_expiration_set", - "CheckTitle": "RBAC-enabled Key Vault has expiration date set for all enabled secrets", + "CheckTitle": "Secret in RBAC-enabled Key Vault has expiration date set", "CheckType": [], "ServiceName": "keyvault", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "microsoft.keyvault/vaults", + "ResourceType": "microsoft.keyvault/vaults/secrets", "ResourceGroup": "security", - "Description": "**Azure Key Vault (RBAC)** secrets are assessed to confirm every **enabled secret** has an `exp` (expiration) date configured", + "Description": "Each **enabled secret** in an **Azure Key Vault (RBAC)** is assessed to confirm it has an `exp` (expiration) date configured.", "Risk": "Without an **expiration**, secrets become perpetual credentials. Leaked or abandoned values can grant persistent access, undermining **confidentiality** and **integrity**. Attackers can reuse old secrets to maintain footholds, perform unauthorized API calls, and exfiltrate data.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py index cd5ec567aa..3515f40f08 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py +++ b/prowler/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set.py @@ -5,21 +5,21 @@ from prowler.providers.azure.services.keyvault.keyvault_client import keyvault_c class keyvault_rbac_secret_expiration_set(Check): def execute(self) -> Check_Report_Azure: findings = [] - for subscription, key_vaults in keyvault_client.key_vaults.items(): for keyvault in key_vaults: - if keyvault.properties.enable_rbac_authorization and keyvault.secrets: - for secret in keyvault.secrets: + if keyvault.properties.enable_rbac_authorization: + for secret in keyvault.secrets or []: + if not secret.enabled: + continue report = Check_Report_Azure( metadata=self.metadata(), resource=secret ) report.subscription = subscription - if not secret.attributes.expires and secret.enabled: + if not secret.attributes.expires: report.status = "FAIL" - report.status_extended = f"Secret '{secret.name}' in KeyVault '{keyvault.name}' does not have expiration date set." + report.status_extended = f"Secret {secret.name} in Key Vault {keyvault.name} from subscription {subscription} does not have an expiration date set." else: report.status = "PASS" - report.status_extended = f"Secret '{secret.name}' in KeyVault '{keyvault.name}' has expiration date set." + report.status_extended = f"Secret {secret.name} in Key Vault {keyvault.name} from subscription {subscription} has an expiration date set." findings.append(report) - return findings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py index 03c94bcfed..5071da4b20 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated.py @@ -21,9 +21,9 @@ class mysql_flexible_server_audit_log_connection_activated(Check): "audit_log_events" ].resource_id - if "CONNECTION" in server.configurations[ + if "connection" in server.configurations[ "audit_log_events" - ].value.split(","): + ].value.lower().split(","): report.status = "PASS" report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name}." diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py index c8ae94fb31..81918f7756 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled.py @@ -21,7 +21,7 @@ class mysql_flexible_server_audit_log_enabled(Check): "audit_log_enabled" ].resource_id - if server.configurations["audit_log_enabled"].value == "ON": + if server.configurations["audit_log_enabled"].value.lower() == "on": report.status = "PASS" report.status_extended = f"Audit log is enabled for server {server.name} in subscription {subscription_name}." diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py index a18a1aba5e..79930de947 100644 --- a/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled.py @@ -20,7 +20,10 @@ class mysql_flexible_server_ssl_connection_enabled(Check): report.resource_id = server.configurations[ "require_secure_transport" ].resource_id - if server.configurations["require_secure_transport"].value == "ON": + if ( + server.configurations["require_secure_transport"].value.lower() + == "on" + ): report.status = "PASS" report.status_extended = f"SSL connection is enabled for server {server.name} in subscription {subscription_name}." diff --git a/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py index e9da4662ed..d5865937f9 100644 --- a/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py +++ b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py @@ -31,7 +31,8 @@ class vm_backup_enabled(Check): for backup_item in vault.backup_protected_items.values(): if ( backup_item.workload_type == DataSourceType.VM - and backup_item.name.split(";")[-1] == vm.resource_name + and backup_item.name.split(";")[-1].lower() + == vm.resource_name.lower() ): found = True found_vault_name = vault.name diff --git a/prowler/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period.py b/prowler/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period.py index 221df85351..444cfcfa3b 100644 --- a/prowler/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period.py +++ b/prowler/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period.py @@ -27,7 +27,8 @@ class vm_sufficient_daily_backup_retention_period(Check): for backup_item in vault.backup_protected_items.values(): if ( backup_item.workload_type == DataSourceType.VM - and backup_item.name.split(";")[-1] == vm.resource_name + and backup_item.name.split(";")[-1].lower() + == vm.resource_name.lower() ): backup_found = True policy_id = backup_item.backup_policy_id diff --git a/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.metadata.json b/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.metadata.json index ff043c2122..e17c3b7308 100644 --- a/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.metadata.json +++ b/prowler/providers/cloudflare/services/dns/dns_record_cname_target_valid/dns_record_cname_target_valid.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "DNSRecord", "ResourceGroup": "network", "Description": "**Cloudflare DNS records** (CNAME, MX, NS, SRV) that point to hostnames are assessed for **dangling record** vulnerabilities by checking if the target domain resolves to a valid address, preventing **subdomain takeover**, **mail interception**, and **service hijacking** attacks.", - "Risk": "Dangling **DNS records** pointing to non-existent targets create multiple vulnerabilities.\n- **Confidentiality**: dangling CNAME/NS allows subdomain takeover; dangling MX allows mail interception\n- **Integrity**: attackers can impersonate your organization, intercept emails, or hijack services\n- **Availability**: legitimate services may be disrupted or redirected to attacker-controlled infrastructure", + "Risk": "Dangling **DNS records** pointing to non-existent targets create multiple vulnerabilities. Dangling CNAME/NS allows **subdomain takeover**; dangling MX allows **mail interception**. Attackers can impersonate your organization, hijack services, or redirect legitimate traffic to attacker-controlled infrastructure.", "RelatedUrl": "", "AdditionalURLs": [ "https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/" diff --git a/prowler/providers/cloudflare/services/dns/dns_service.py b/prowler/providers/cloudflare/services/dns/dns_service.py index bc718f3c5f..ff671bdfc6 100644 --- a/prowler/providers/cloudflare/services/dns/dns_service.py +++ b/prowler/providers/cloudflare/services/dns/dns_service.py @@ -21,7 +21,7 @@ class DNS(CloudflareService): # Get zones directly from API to avoid circular dependency with zone_client zones = self._get_zones() - for zone_id, zone_name in zones.items(): + for zone_id, (zone_name, account_id) in zones.items(): seen_record_ids: set[str] = set() try: for record in self.client.dns.records.list(zone_id=zone_id): @@ -36,6 +36,7 @@ class DNS(CloudflareService): id=record_id, zone_id=zone_id, zone_name=zone_name, + account_id=account_id, name=getattr(record, "name", None), type=getattr(record, "type", None), content=getattr(record, "content", ""), @@ -52,11 +53,11 @@ class DNS(CloudflareService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - def _get_zones(self) -> dict[str, str]: + def _get_zones(self) -> dict[str, tuple[str, str]]: """Get zones directly from Cloudflare API. Returns: - Dictionary mapping zone_id to zone_name. + Dictionary mapping zone_id to (zone_name, account_id). """ zones = {} audited_accounts = self.provider.identity.audited_accounts @@ -88,7 +89,7 @@ class DNS(CloudflareService): ): continue - zones[zone_id] = zone_name + zones[zone_id] = (zone_name, account_id or "") except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -103,6 +104,7 @@ class CloudflareDNSRecord(BaseModel): id: str zone_id: str zone_name: str + account_id: str = "" name: Optional[str] = None type: Optional[str] = None content: str = "" diff --git a/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json index 07e9181831..b7568badb8 100644 --- a/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_record_caa_exists/zone_record_caa_exists.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "Zone", "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **CAA (Certificate Authority Authorization)** DNS records by checking if they exist with **`issue` or `issuewild` tags** that specify which **certificate authorities** are permitted to issue SSL/TLS certificates for the domain.", - "Risk": "Without **CAA** records or without `issue`/`issuewild` tags, any certificate authority can issue certificates for your domain.\n- **Confidentiality**: unauthorized certificates enable man-in-the-middle attacks\n- **Integrity**: attackers can impersonate your domain with fraudulently obtained certificates\n- **Trust**: CA compromise or social engineering can result in unauthorized certificate issuance\n- **Missing tags**: CAA records without `issue` or `issuewild` tags (e.g., only `iodef`) do not restrict certificate issuance", + "Risk": "Without **CAA** records or `issue`/`issuewild` tags, any certificate authority can issue certificates for your domain. Unauthorized certificates enable **man-in-the-middle attacks** and domain impersonation. CAA records with only `iodef` tags do not restrict certificate issuance.", "RelatedUrl": "", "AdditionalURLs": [ "https://developers.cloudflare.com/ssl/edge-certificates/caa-records/" diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json index a8811febbf..aaf204e29f 100644 --- a/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_record_dkim_exists/zone_record_dkim_exists.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "Zone", "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **DKIM (DomainKeys Identified Mail)** records by checking if TXT records exist at `*._domainkey` subdomains containing a **cryptographically valid public key** in the `p=` parameter used to **verify email signatures**.", - "Risk": "Without **DKIM** or with a revoked/empty public key, email recipients cannot verify that messages were sent by authorized servers.\n- **Confidentiality**: attackers can forge emails appearing to come from your domain\n- **Integrity**: no cryptographic proof that email content hasn't been modified in transit\n- **Reputation**: DMARC policies relying on DKIM will fail, affecting email deliverability\n- **Revoked keys**: A DKIM record with `p=` empty (e.g., `p=;`) indicates a revoked key that cannot authenticate emails", + "Risk": "Without **DKIM** or with a revoked/empty public key, recipients cannot verify messages were sent by authorized servers. Attackers can forge emails from your domain, and content integrity cannot be proven. DMARC policies relying on DKIM will fail, affecting deliverability. A DKIM record with empty `p=` indicates a revoked key.", "RelatedUrl": "", "AdditionalURLs": [ "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" diff --git a/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json index 2aa572da36..93ccedacfd 100644 --- a/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_record_dmarc_exists/zone_record_dmarc_exists.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "Zone", "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **DMARC (Domain-based Message Authentication, Reporting, and Conformance)** records by checking if a TXT record exists at `_dmarc` subdomain with an **enforcement policy (`p=reject` or `p=quarantine`)** to actively block or quarantine spoofed emails.", - "Risk": "Without **DMARC** or with a monitoring-only policy (`p=none`), there is no active protection against email spoofing.\n- **Confidentiality**: attackers can spoof emails for phishing campaigns to steal credentials\n- **Integrity**: no visibility into email authentication failures or abuse attempts\n- **Reputation**: domain reputation damage from spoofed emails sent in your name\n- **Monitoring-only policy**: `p=none` only generates reports but does not block or quarantine spoofed emails, providing no real protection", + "Risk": "Without **DMARC** or with `p=none`, there is no active protection against email spoofing. Attackers can spoof emails for **phishing campaigns** to steal credentials. Domain reputation is damaged by spoofed emails. The `p=none` policy only generates reports but does not block or quarantine spoofed emails.", "RelatedUrl": "", "AdditionalURLs": [ "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" diff --git a/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json index 07e2d4ee5f..d043a44136 100644 --- a/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_record_spf_exists/zone_record_spf_exists.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "Zone", "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **SPF (Sender Policy Framework)** records by checking if a TXT record exists that specifies which mail servers are **authorized to send email** on behalf of the domain, and verifies that the record uses a **strict policy (`-all`)** to reject unauthorized senders.", - "Risk": "Without **SPF** or with a permissive policy (`~all`, `?all`, `+all`), attackers can forge emails appearing to come from your domain.\n- **Confidentiality**: phishing attacks can harvest sensitive information from recipients who trust spoofed emails\n- **Integrity**: brand reputation damage from fraudulent emails sent in your name\n- **Availability**: email deliverability issues as receiving servers may reject or quarantine legitimate emails\n- **Permissive policies**: `~all` (softfail) only marks emails as suspicious but does not reject them, `?all` (neutral) provides no protection", + "Risk": "Without **SPF** or with a permissive policy (`~all`, `?all`, `+all`), attackers can forge emails from your domain. Phishing attacks can harvest sensitive information from recipients who trust spoofed emails. Brand reputation is damaged by fraudulent emails. `~all` only marks emails as suspicious without rejecting them.", "RelatedUrl": "", "AdditionalURLs": [ "https://developers.cloudflare.com/dns/manage-dns-records/how-to/email-records/" diff --git a/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json index d236895198..8a3f2f31c2 100644 --- a/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json +++ b/prowler/providers/cloudflare/services/zone/zone_ssl_strict/zone_ssl_strict.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "Zone", "ResourceGroup": "network", "Description": "**Cloudflare zones** are assessed for **SSL/TLS encryption mode** by checking if the mode is set to `Full (Strict)` to ensure **end-to-end encryption** with certificate validation.", - "Risk": "Without **strict SSL mode**, traffic between Cloudflare and origin may use unvalidated or unencrypted connections.\n- **Confidentiality**: sensitive data can be intercepted in transit via man-in-the-middle attacks\n- **Integrity**: responses can be modified without detection between Cloudflare and origin\n- **Compliance**: may violate PCI-DSS, HIPAA, and other regulatory requirements for encrypted transport", + "Risk": "Without **strict SSL mode**, traffic between Cloudflare and origin may use unvalidated or unencrypted connections. Sensitive data can be intercepted via **man-in-the-middle attacks**, responses can be modified without detection, and this may violate PCI-DSS, HIPAA, and other regulatory requirements.", "RelatedUrl": "", "AdditionalURLs": [ "https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/" diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 0f028106bb..debc1c4ef2 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -238,6 +238,21 @@ class Provider(ABC): fixer_config=fixer_config, ) elif "github" in provider_class_name.lower(): + orgs = [] + repos = [] + + if getattr(arguments, "organization", None): + orgs.extend(arguments.organization) + if getattr(arguments, "organizations", None): + orgs.extend(arguments.organizations) + if getattr(arguments, "repository", None): + repos.extend(arguments.repository) + if getattr(arguments, "repositories", None): + repos.extend(arguments.repositories) + + orgs = list(dict.fromkeys(orgs)) + repos = list(dict.fromkeys(repos)) + provider_class( personal_access_token=arguments.personal_access_token, oauth_app_token=arguments.oauth_app_token, @@ -245,8 +260,8 @@ class Provider(ABC): github_app_id=arguments.github_app_id, mutelist_path=arguments.mutelist_file, config_path=arguments.config_file, - repositories=arguments.repository, - organizations=arguments.organization, + repositories=repos, + organizations=orgs, ) elif "googleworkspace" in provider_class_name.lower(): provider_class( @@ -292,6 +307,12 @@ class Provider(ABC): timeout=arguments.timeout, config_path=arguments.config_file, fixer_config=fixer_config, + registry=arguments.registry, + image_filter=arguments.image_filter, + tag_filter=arguments.tag_filter, + max_images=arguments.max_images, + registry_insecure=arguments.registry_insecure, + registry_list_images=arguments.registry_list_images, ) elif "mongodbatlas" in provider_class_name.lower(): provider_class( @@ -306,7 +327,7 @@ class Provider(ABC): provider_class( oci_config_file=arguments.oci_config_file, profile=arguments.profile, - region=arguments.region, + region=set(arguments.region) if arguments.region else None, compartment_ids=arguments.compartment_id, config_path=arguments.config_file, mutelist_path=arguments.mutelist_file, @@ -350,6 +371,13 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif "vercel" in provider_class_name.lower(): + provider_class( + projects=getattr(arguments, "project", None), + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + ) except TypeError as error: logger.critical( diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index c9e7c25501..5017b84c42 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -626,9 +626,8 @@ class GcpProvider(Provider): Usage: >>> GcpProvider.get_projects(credentials=credentials, organization_id=organization_id) """ + projects = {} try: - projects = {} - if organization_id: try: # Initialize Cloud Asset Inventory API for recursive project retrieval @@ -803,8 +802,7 @@ class GcpProvider(Provider): logger.critical( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - finally: - return projects + return projects def update_projects_with_organizations(self): """ diff --git a/prowler/providers/gcp/lib/constants.py b/prowler/providers/gcp/lib/constants.py new file mode 100644 index 0000000000..35756bd386 --- /dev/null +++ b/prowler/providers/gcp/lib/constants.py @@ -0,0 +1 @@ +GEMINI_SERVICE_NAME = "generativelanguage.googleapis.com" diff --git a/prowler/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/__init__.py b/prowler/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api.metadata.json b/prowler/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api.metadata.json new file mode 100644 index 0000000000..b1a80ad12d --- /dev/null +++ b/prowler/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "gcp", + "CheckID": "apikeys_api_restricted_with_gemini_api", + "CheckTitle": "API key is restricted to specific Google APIs when the Gemini (Generative Language) API is enabled", + "CheckType": [], + "ServiceName": "apikeys", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "apikeys.googleapis.com/Key", + "ResourceGroup": "IAM", + "Description": "The Gemini API (a.k.a. Generative Language API, `generativelanguage.googleapis.com`) uses API keys to authenticate requests for uploaded files and cached content. For projects with the Gemini API enabled, each API key should have API restrictions configured to limit access to specific services.", + "Risk": "Leaked, unrestricted API keys allow access to sensitive uploaded files and cached content, impacting their **confidentiality**. The risk of leaks is particularly high because traditionally, Google API keys were *not* considered sensitive and might be embedded in website source code, mobile apps, etc.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://trufflesecurity.com/blog/google-api-keys-werent-secrets-but-then-gemini-changed-the-rules", + "https://cloud.google.com/docs/authentication/api-keys" + ], + "Remediation": { + "Code": { + "CLI": "gcloud services api-keys update --api-target=service=", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to APIs & Services > Credentials\n2. Click the API key name to edit it\n3. In API restrictions, select \"Restrict key\"\n4. Choose only the specific API(s) needed (do not select \"All Google APIs\" and select \"Generative Language API\" only if specifically required)\n5. Click Save", + "Terraform": "```hcl\nresource \"google_apikeys_key\" \"key\" {\n display_name = \"\"\n\n restrictions {\n api_targets {\n service = \"\" # Critical: restricts the key to a specific API, removing any \"All Google APIs\" (cloudapis.googleapis.com)\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Apply **API restrictions** so that each key only has access to specific APIs. Do *not* allow access to the \"Generative Language API\" in particular.\nIf access to the \"Generative Language API\" is required, be aware of the consequences. Only use the key for this specific purpose and ensure adequate **secrets storage**.\n\nAlternatively, completely disable the Gemini API (a.k.a. Generative Language API, `generativelanguage.googleapis.com`) in the project's enabled services.", + "Url": "https://hub.prowler.com/check/apikeys_api_restricted_with_gemini_api" + } + }, + "Categories": [ + "secrets", + "gen-ai", + "identity-access" + ], + "DependsOn": [ + "apikeys_api_restrictions_configured" + ], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api.py b/prowler/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api.py new file mode 100644 index 0000000000..80002fb727 --- /dev/null +++ b/prowler/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api.py @@ -0,0 +1,53 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.lib.constants import GEMINI_SERVICE_NAME +from prowler.providers.gcp.services.apikeys.apikeys_client import apikeys_client +from prowler.providers.gcp.services.serviceusage.serviceusage_client import ( + serviceusage_client, +) + + +class apikeys_api_restricted_with_gemini_api(Check): + def execute(self) -> Check_Report_GCP: + findings = [] + + for key in apikeys_client.keys: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=key, + location=apikeys_client.region, + ) + report.status = "PASS" + report.status_extended = f"API key {key.name} has restrictions configured." + + genlang_enabled = any( + active_service.name == GEMINI_SERVICE_NAME + for active_service in serviceusage_client.active_services.get( + key.project_id, [] + ) + ) + + if not genlang_enabled: + report.status = "PASS" + report.status_extended = f"Gemini (Generative Language) API is not enabled for project {key.project_id} of key {key.name}" + findings.append(report) + continue + + key_restrictions = key.restrictions.get("apiTargets", []) + + if len(key_restrictions) > 1 and any( + target.get("service") == GEMINI_SERVICE_NAME + for target in key_restrictions + ): + report.status = "FAIL" + report.status_extended = f"API key {key.name} has access to Gemini (Generative Language) API as well as other APIs." + + elif not key_restrictions or any( + target.get("service") == "cloudapis.googleapis.com" + for target in key_restrictions + ): + report.status = "FAIL" + report.status_extended = f"API key {key.name} does not have restrictions configured and Gemini (Generative Language) API is enabled for project {key.project_id}." + + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/compute/compute_image_not_publicly_shared/compute_image_not_publicly_shared.metadata.json b/prowler/providers/gcp/services/compute/compute_image_not_publicly_shared/compute_image_not_publicly_shared.metadata.json index a24d47ec73..541002b8b0 100644 --- a/prowler/providers/gcp/services/compute/compute_image_not_publicly_shared/compute_image_not_publicly_shared.metadata.json +++ b/prowler/providers/gcp/services/compute/compute_image_not_publicly_shared/compute_image_not_publicly_shared.metadata.json @@ -9,7 +9,7 @@ "Severity": "medium", "ResourceType": "compute.googleapis.com/Image", "ResourceGroup": "compute", - "Description": "Custom disk images should not be shared publicly with **allAuthenticatedUsers**.\n\nNote: Per Google Cloud API restrictions, **allUsers** cannot be assigned to Compute Engine images. The security concern is **allAuthenticatedUsers**, which grants access to anyone with a Google account.\n\nPublicly shared disk images can expose application snapshots and sensitive data to anyone with a Google Cloud account, potentially leading to unauthorized access and data breaches.", + "Description": "Custom disk images should not be shared publicly with **allAuthenticatedUsers**. Per Google Cloud API restrictions, **allUsers** cannot be assigned to Compute Engine images. The concern is **allAuthenticatedUsers**, which grants access to anyone with a Google account, potentially exposing application snapshots and sensitive data.", "Risk": "Publicly shared disk images can expose **sensitive data** and application configurations to unauthorized users.\n\n- Any authenticated GCP user can access the image content\n- Could lead to **data breaches** if images contain secrets or proprietary code\n- Attackers may use exposed images to understand application architecture", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/gcp/services/compute/compute_instance_group_autohealing_enabled/compute_instance_group_autohealing_enabled.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_group_autohealing_enabled/compute_instance_group_autohealing_enabled.metadata.json index f364573e2e..acdf48ed00 100644 --- a/prowler/providers/gcp/services/compute/compute_instance_group_autohealing_enabled/compute_instance_group_autohealing_enabled.metadata.json +++ b/prowler/providers/gcp/services/compute/compute_instance_group_autohealing_enabled/compute_instance_group_autohealing_enabled.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "compute.googleapis.com/InstanceGroupManager", "ResourceGroup": "compute", "Description": "Managed Instance Groups (MIGs) should have **autohealing** enabled with a valid health check configured. Autohealing automatically recreates unhealthy instances based on application-level health checks, ensuring continuous availability.", - "Risk": "Without autohealing, MIGs cannot detect application-level failures such as crashes, freezes, or memory issues. Instances that are technically running but experiencing problems will remain undetected and unreplaced, leading to:\n\n- **Service degradation** from unhealthy instances\n- **Extended downtime** during application failures\n- **Manual intervention** required to detect and replace failed instances", + "Risk": "Without autohealing, MIGs cannot detect application-level failures such as crashes, freezes, or memory issues. Instances experiencing problems remain undetected and unreplaced, leading to **service degradation**, **extended downtime**, and requiring **manual intervention** to detect and replace failed instances.", "RelatedUrl": "", "AdditionalURLs": [ "https://cloud.google.com/compute/docs/instance-groups/autohealing-instances-in-migs" diff --git a/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.metadata.json index 927e72874a..3b7442bf2a 100644 --- a/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.metadata.json +++ b/prowler/providers/gcp/services/compute/compute_instance_group_multiple_zones/compute_instance_group_multiple_zones.metadata.json @@ -1,7 +1,7 @@ { "Provider": "gcp", "CheckID": "compute_instance_group_multiple_zones", - "CheckTitle": "Ensure Managed Instance Groups span multiple zones for high availability", + "CheckTitle": "Managed Instance Groups span multiple zones for high availability", "CheckType": [], "ServiceName": "compute", "SubServiceName": "", diff --git a/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.metadata.json b/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.metadata.json index 3e65d6f844..aa2f15a157 100644 --- a/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.metadata.json +++ b/prowler/providers/gcp/services/compute/compute_instance_suspended_without_persistent_disks/compute_instance_suspended_without_persistent_disks.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "compute.googleapis.com/Instance", "ResourceGroup": "compute", "Description": "This check identifies VM instances in a **SUSPENDED** or **SUSPENDING** state with persistent disks still attached.\n\nPersistent disks on suspended VMs remain accessible through the GCP API and could contain **sensitive data** while the instance is inactive, potentially creating security blind spots in long-forgotten infrastructure.", - "Risk": "Persistent disks on suspended VM instances remain accessible through the GCP API and may contain **sensitive data**, creating potential security risks:\n\n- **Unauthorized data access** if credentials are compromised or permissions are misconfigured\n- **Data exposure** from forgotten infrastructure that is no longer actively monitored\n- **Security blind spots** where suspended resources are overlooked during security reviews and audits", + "Risk": "Persistent disks on suspended VM instances remain accessible through the GCP API and may contain **sensitive data**. This creates risks of **unauthorized data access** if permissions are misconfigured, **data exposure** from forgotten unmonitored infrastructure, and **security blind spots** where suspended resources are overlooked during reviews.", "RelatedUrl": "", "AdditionalURLs": [ "https://cloud.google.com/icompute/docs/instances/suspend-resume-instance" diff --git a/prowler/providers/gcp/services/gemini/__init__.py b/prowler/providers/gcp/services/gemini/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/gemini/gemini_api_disabled/__init__.py b/prowler/providers/gcp/services/gemini/gemini_api_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled.metadata.json b/prowler/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled.metadata.json new file mode 100644 index 0000000000..727c6b666d --- /dev/null +++ b/prowler/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "gcp", + "CheckID": "gemini_api_disabled", + "CheckTitle": "Gemini (Generative Language) API is disabled", + "CheckType": [], + "ServiceName": "gemini", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "serviceusage.googleapis.com/Service", + "ResourceGroup": "ai_ml", + "Description": "The Gemini API (a.k.a. Generative Language API, `generativelanguage.googleapis.com`) should not be used. It does not adhere to GCP's general security & compliance standards.", + "Risk": "The API does not support GCP IAM for authentication, impacting confidentiality of uploaded files and cached content. It provides no SLA, reducing availability guarantees. It is not covered by compliance certifications such as ISO 27001, SOC 2, C5, and HIPAA, which can diminish your compliance posture.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/docs/authentication/api-keys", + "https://cloud.google.com/security/compliance/iso-27001", + "https://cloud.google.com/security/compliance/soc-2", + "https://cloud.google.com/security/compliance/bsi-c5", + "https://cloud.google.com/security/compliance/hipaa-compliance" + ], + "Remediation": { + "Code": { + "CLI": "gcloud services disable generativelanguage.googleapis.com", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to \"APIs & Services\" > \"Enabled APIs & services\"\n2. Click \"Generative Language API\" to view service details.\n3. Click \"Disable API\" in the top bar and confirm the action.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Do not use the Gemini API. Instead, consider using Gemini through the Vertex AI API, which does not have the described issues.", + "Url": "https://hub.prowler.com/check/gemini_api_disabled" + } + }, + "Categories": [ + "gen-ai", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled.py b/prowler/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled.py new file mode 100644 index 0000000000..0b979df5e0 --- /dev/null +++ b/prowler/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.lib.constants import GEMINI_SERVICE_NAME +from prowler.providers.gcp.services.serviceusage.serviceusage_client import ( + serviceusage_client, +) + + +class gemini_api_disabled(Check): + def execute(self) -> Check_Report_GCP: + findings = [] + + for project_id in serviceusage_client.project_ids: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=serviceusage_client.projects[project_id], + resource_id=GEMINI_SERVICE_NAME, + resource_name="Gemini (Generative Language) API", + project_id=project_id, + location=serviceusage_client.region, + ) + report.status = "FAIL" + report.status_extended = ( + f"Gemini (Generative Language) API is enabled for project {project_id}" + ) + + genlang_enabled = any( + active_service.name == GEMINI_SERVICE_NAME + for active_service in serviceusage_client.active_services.get( + project_id, [] + ) + ) + + if not genlang_enabled: + report.status = "PASS" + report.status_extended = f"Gemini (Generative Language) API is disabled for project {project_id}" + + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.metadata.json b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.metadata.json index d2eb0c8ef3..6155533a33 100644 --- a/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.metadata.json +++ b/prowler/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "MetricFilter", "ResourceGroup": "monitoring", "Description": "Log metric filters and alerts for **Compute Engine configuration changes** provide visibility into modifications to instances, disks, networks, firewalls, and routes. These monitoring controls enable security teams to detect unauthorized changes and investigate suspicious infrastructure modifications.", - "Risk": "Without monitoring for Compute Engine configuration changes, **unauthorized modifications** to compute resources may go undetected. Attackers can establish **persistence** through instance modifications, escalate privileges via IAM policy changes, disable security controls, or pivot to other resources. This compromises **confidentiality**, **integrity**, and **availability** of workloads and may enable **data exfiltration** or **lateral movement**.", + "Risk": "Without monitoring for Compute Engine configuration changes, **unauthorized modifications** may go undetected. Attackers can establish **persistence**, escalate privileges, disable security controls, or pivot to other resources, compromising **confidentiality**, **integrity**, and **availability** of workloads.", "RelatedUrl": "", "AdditionalURLs": [ "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/gcp/ComputeEngine/gcp-compute-engine-configuration-changes.html", diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index 9088b79c6f..16d13f7434 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -139,8 +139,20 @@ class GithubProvider(Provider): logging.getLogger("github.GithubRetry").setLevel(logging.CRITICAL) # Set repositories and organizations for scoping - self._repositories = repositories or [] - self._organizations = organizations or [] + # Normalize single strings into lists (argparse sometimes passes str for singular flags) + if repositories is None: + self._repositories = [] + elif isinstance(repositories, str): + self._repositories = [repositories] + else: + self._repositories = list(repositories) + + if organizations is None: + self._organizations = [] + elif isinstance(organizations, str): + self._organizations = [organizations] + else: + self._organizations = list(organizations) self._session = GithubProvider.setup_session( personal_access_token, diff --git a/prowler/providers/github/lib/arguments/arguments.py b/prowler/providers/github/lib/arguments/arguments.py index 57aed863ab..748d77a927 100644 --- a/prowler/providers/github/lib/arguments/arguments.py +++ b/prowler/providers/github/lib/arguments/arguments.py @@ -1,3 +1,6 @@ +SENSITIVE_ARGUMENTS = frozenset({"--personal-access-token", "--oauth-app-token"}) + + def init_parser(self): """Init the Github Provider CLI parser""" github_parser = self.subparsers.add_parser( diff --git a/prowler/providers/github/services/organization/organization_default_repository_permission_strict/organization_default_repository_permission_strict.metadata.json b/prowler/providers/github/services/organization/organization_default_repository_permission_strict/organization_default_repository_permission_strict.metadata.json index 49c958bde3..fd41aa03ec 100644 --- a/prowler/providers/github/services/organization/organization_default_repository_permission_strict/organization_default_repository_permission_strict.metadata.json +++ b/prowler/providers/github/services/organization/organization_default_repository_permission_strict/organization_default_repository_permission_strict.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GitHubOrganization", + "ResourceType": "NotDefined", "ResourceGroup": "governance", "Description": "**GitHub organization** base repository permission for members uses a **strict setting** such as `read` or `none` rather than permissive options like `write` or `admin`. *Applies to members, not outside collaborators.*", "Risk": "**Excessive default permissions** (`write`/`admin`) erode code **integrity** and **availability**.\n\nAny member-or a compromised account-can alter many repos, inject malicious commits, change tags/releases, or delete branches, enabling supply-chain compromise and large-scale disruptions.", diff --git a/prowler/providers/github/services/organization/organization_members_mfa_required/organization_members_mfa_required.metadata.json b/prowler/providers/github/services/organization/organization_members_mfa_required/organization_members_mfa_required.metadata.json index ad4af4eb84..7388171ba0 100644 --- a/prowler/providers/github/services/organization/organization_members_mfa_required/organization_members_mfa_required.metadata.json +++ b/prowler/providers/github/services/organization/organization_members_mfa_required/organization_members_mfa_required.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "GitHubOrganization", + "ResourceType": "NotDefined", "ResourceGroup": "governance", "Description": "GitHub organization settings require all members to use **two-factor authentication** (2FA).\n\nThe evaluation determines whether access to organization resources is conditioned on members having 2FA enabled.", "Risk": "Without enforced **2FA**, stolen or reused passwords enable account takeover, leading to:\n- Loss of code integrity via unauthorized commits\n- Confidential data exposure from repos and secrets\n- Availability impact from settings changes, token revocation, or deletions", diff --git a/prowler/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited.metadata.json b/prowler/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited.metadata.json index 3a0f07bdbd..be2f7ab50f 100644 --- a/prowler/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited.metadata.json +++ b/prowler/providers/github/services/organization/organization_repository_creation_limited/organization_repository_creation_limited.metadata.json @@ -1,27 +1,30 @@ { "Provider": "github", "CheckID": "organization_repository_creation_limited", - "CheckTitle": "Ensure repository creation is limited to trusted organization members.", + "CheckTitle": "Organization repository creation is limited to trusted members", "CheckType": [], "ServiceName": "organization", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GitHubOrganization", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure that repository creation is restricted so that only trusted owners or specific teams can create new repositories within the organization.", - "Risk": "Allowing all members to create repositories increases the likelihood of shadow repositories, data leakage, or malicious projects being introduced without oversight.", - "RelatedUrl": "https://docs.github.com/en/organizations/managing-organization-settings/restricting-repository-creation-in-your-organization", + "Description": "**GitHub organization** repository creation is restricted so that only trusted owners or specific teams can create new repositories within the organization.", + "Risk": "**Excessive default permissions** (`write`/`admin`) erode code **integrity** and **availability**.Any member-or a compromised account-can alter many repos, inject malicious commits, change tags/releases, or delete branches, enabling supply-chain compromise and large-scale disruptions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/organizations/managing-organization-settings/restricting-repository-creation-in-your-organization" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to GitHub as an organization owner\n2. Go to your organization > Settings\n3. In the left sidebar, click \"Access\" > \"Member privileges\"\n4. Under \"Repository creation\", select \"Restrict repository creation\"\n5. Click \"Save\"", "Terraform": "" }, "Recommendation": { - "Text": "Disable repository creation for members or limit it to specific trusted teams by adjusting Member privileges in the organization's settings.", - "Url": "https://docs.github.com/en/organizations/managing-organization-settings/restricting-repository-creation-in-your-organization" + "Text": "Disable repository creation for members or limit it to specific trusted teams by adjusting **Member privileges** in the organization's settings.", + "Url": "https://hub.prowler.com/check/organization_repository_creation_limited" } }, "Categories": [], diff --git a/prowler/providers/github/services/organization/organization_repository_deletion_limited/__init__.py b/prowler/providers/github/services/organization/organization_repository_deletion_limited/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.metadata.json b/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.metadata.json new file mode 100644 index 0000000000..851848ce5a --- /dev/null +++ b/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "github", + "CheckID": "organization_repository_deletion_limited", + "CheckTitle": "Organization repository deletion and transfer is restricted to owners", + "CheckType": [], + "ServiceName": "organization", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "GitHubOrganization", + "ResourceGroup": "governance", + "Description": "Ensure repository deletion/transfer is restricted so only trusted organization users (owners) can delete or transfer repositories.", + "Risk": "If members can delete or transfer repositories, accidental or malicious deletions can cause irreversible data loss, service disruption, and increased blast radius from compromised accounts.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to GitHub as an organization owner\n2. Go to Organization > Settings\n3. Under \"Access\", click \"Member privileges\"\n4. Disable \"Allow members to delete or transfer repositories\"\n5. Save changes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable member repository deletion/transfer privileges so only organization owners can delete or transfer repositories.", + "Url": "https://hub.prowler.com/check/organization_repository_deletion_limited" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + "AdditionalURLs": [ + "https://docs.github.com/en/organizations/managing-organization-settings/setting-permissions-for-deleting-or-transferring-repositories" + ] +} diff --git a/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.py b/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.py new file mode 100644 index 0000000000..5d94ad5af6 --- /dev/null +++ b/prowler/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited.py @@ -0,0 +1,31 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.organization.organization_client import ( + organization_client, +) + + +class organization_repository_deletion_limited(Check): + """Check if repository deletion/transfer is limited to trusted organization users.""" + + def execute(self) -> List[CheckReportGithub]: + findings = [] + for org in organization_client.organizations.values(): + members_can_delete = org.members_can_delete_repositories + + if members_can_delete is None: + continue + + report = CheckReportGithub(metadata=self.metadata(), resource=org) + + if members_can_delete is False: + report.status = "PASS" + report.status_extended = f"Organization {org.name} restricts repository deletion/transfer to trusted users." + else: + report.status = "FAIL" + report.status_extended = f"Organization {org.name} allows members to delete/transfer repositories." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/organization/organization_service.py b/prowler/providers/github/services/organization/organization_service.py index 187da72f68..0aceca423e 100644 --- a/prowler/providers/github/services/organization/organization_service.py +++ b/prowler/providers/github/services/organization/organization_service.py @@ -76,6 +76,7 @@ class Organization(GithubService): id=user.id, name=user.login, mfa_required=None, # Users don't have MFA requirements like orgs + members_can_delete_repositories=None, is_verified=None, ) logger.info( @@ -189,6 +190,9 @@ class Organization(GithubService): repo_creation_settings["members_allowed_repository_creation_type"] = ( _extract_flag("members_allowed_repository_creation_type", str) ) + members_can_delete_repositories = _extract_flag( + "members_can_delete_repositories", bool + ) base_permission_raw = _extract_flag("default_repository_permission", str) base_permission = ( @@ -201,6 +205,7 @@ class Organization(GithubService): id=org.id, name=org.login, mfa_required=require_mfa, + members_can_delete_repositories=members_can_delete_repositories, members_can_create_repositories=repo_creation_settings[ "members_can_create_repositories" ], @@ -227,6 +232,7 @@ class Org(BaseModel): id: int name: str mfa_required: Optional[bool] = False + members_can_delete_repositories: Optional[bool] = None members_can_create_repositories: Optional[bool] = None members_can_create_public_repositories: Optional[bool] = None members_can_create_private_repositories: Optional[bool] = None diff --git a/prowler/providers/github/services/organization/organization_verified_badge/organization_verified_badge.metadata.json b/prowler/providers/github/services/organization/organization_verified_badge/organization_verified_badge.metadata.json index 83bcb7a9dd..719e63af1e 100644 --- a/prowler/providers/github/services/organization/organization_verified_badge/organization_verified_badge.metadata.json +++ b/prowler/providers/github/services/organization/organization_verified_badge/organization_verified_badge.metadata.json @@ -1,16 +1,16 @@ { "Provider": "github", "CheckID": "organization_verified_badge", - "CheckTitle": "Ensure GitHub organization has a verified badge", + "CheckTitle": "Organization has a verified badge", "CheckType": [], "ServiceName": "organization", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "GitHubOrganization", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Checks whether a GitHub organization has a verified badge.", - "Risk": "Unverified organizations may be easier to impersonate, increasing the risk of phishing or trust abuse.", + "Description": "**GitHub organization** has a **verified badge**.", + "Risk": "**Unverified organizations** may be easier to impersonate, increasing the risk of phishing or trust abuse.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.github.com/en/organizations/managing-organization-settings/verifying-or-approving-a-domain-for-your-organization" @@ -19,11 +19,11 @@ "Code": { "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to GitHub as an organization owner\n2. Go to your organization > Settings\n3. In the left sidebar, click \"Verification\"\n4. Click \"Verify\"", "Terraform": "" }, "Recommendation": { - "Text": "Verify the organization identity by completing GitHub organization verification.", + "Text": "Verify the organization identity by completing **GitHub organization verification**.", "Url": "https://hub.prowler.com/check/organization_verified_badge" } }, diff --git a/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.metadata.json b/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.metadata.json index e755eaf803..421f3683cf 100644 --- a/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.metadata.json +++ b/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_branch_delete_on_merge_enabled", - "CheckTitle": "Check if a repository deletes the branch after merging", + "CheckTitle": "Repository deletes branches after pull request merge", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "GitHubRepository", + "Severity": "low", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that the repository deletes the branch after merging.", - "Risk": "Inactive branches pose a security risk as they can accumulate outdated code, dependencies, and potential vulnerabilities over time. Malicious actors may exploit these branches, and they can clutter the repository, making it harder to manage and track the active code. Additionally, stale branches may unintentionally be accessed or used inappropriately, leading to potential security breaches.", - "RelatedUrl": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches", + "Description": "**GitHub repository** setting that enables **automatic deletion of head branches** when pull requests merge into the default branch (`delete_branch_on_merge`).", + "Risk": "Without automatic deletion, merged branches persist, weakening **integrity** and **confidentiality**: outdated code may be reused, secret remnants can linger, and reviews become ambiguous. Stale refs can still trigger CI with obsolete workflows, raising risks of supply-chain tampering and faulty deployments.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-the-automatic-deletion-of-branches", + "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PATCH repos// -f delete_branch_on_merge=true", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the repository and click Settings\n2. Under General, scroll to Pull Requests\n3. Check Automatically delete head branches\n4. Click Save", + "Terraform": "```hcl\nresource \"github_repository\" \"\" {\n name = \"\"\n delete_branch_on_merge = true # Enables automatic deletion of PR head branches after merge\n}\n```" }, "Recommendation": { - "Text": "Regularly review and remove inactive branches from your repositories. This helps reduce the risk of malicious code injection, sensitive data leaks, and unnecessary clutter in the repository. By keeping branches active and up to date, you ensure that your codebase remains secure and manageable.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-the-automatic-deletion-of-branches" + "Text": "Enable **automatic head-branch deletion** after merges to minimize stale refs and confusion.\n- Enforce **least privilege** for branch creation\n- Apply **branch protection** and rulesets\n- Prefer short-lived feature branches with periodic pruning\n- Guard CI to avoid runs from obsolete branches", + "Url": "https://hub.prowler.com/check/repository_branch_delete_on_merge_enabled" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.metadata.json index a7ee516510..fb81586d6b 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.metadata.json +++ b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "github", "CheckID": "repository_default_branch_deletion_disabled", - "CheckTitle": "Check if a repository denies default branch deletion", + "CheckTitle": "Repository denies default branch deletion", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "GitHubRepository", + "Severity": "medium", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that the repository denies default branch deletion.", - "Risk": "Allowing the deletion of protected branches by users with push access increases the risk of accidental or intentional branch removal, potentially resulting in significant data loss or disruption to the development process.", - "RelatedUrl": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#allow-deletions", + "Description": "**GitHub repository default branch** have **branch protections** or **rulesets** with `Allow deletions` disabled.", + "Risk": "Permitting default branch deletion undermines **availability** by breaking CI/CD, releases, and PR targets.\n\nIt also impacts **integrity**: the canonical ref can be removed, enabling history tampering, branch hijacking, and harder audits/rollbacks.", + "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-rulesets/available-rules-for-rulesets" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X DELETE repos///branches//protection/allow_deletions", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the repository and go to Settings > Branches\n2. Edit the branch protection rule for the default branch (or Add rule if none exists)\n3. Ensure \"Allow deletions\" is unchecked\n4. Click Save changes", + "Terraform": "```hcl\nresource \"github_branch_protection_v3\" \"\" {\n repository_id = \"\"\n pattern = \"\"\n allows_deletions = false # Critical: disables deletion of the default branch\n}\n```" }, "Recommendation": { - "Text": "Deny the ability to delete protected branches to ensure the preservation of critical branch data. This prevents accidental or malicious deletions and helps maintain the integrity and stability of the repository.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "Text": "Disable deletions on the **default branch** using **branch protection** or **rulesets** (`Allow deletions=false`). Apply controls to admins, minimize bypass lists, and enforce **least privilege**. Combine with required pull requests and status checks for **defense in depth**.", + "Url": "https://hub.prowler.com/check/repository_default_branch_deletion_disabled" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 dd17c47245..f774f204d0 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 @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_default_branch_disallows_force_push", - "CheckTitle": "Check if repository denies force push", + "CheckTitle": "Repository default branch denies force pushes", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GithubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that the repository denies force push to protected branches.", - "Risk": "Permitting force pushes to branches can lead to accidental or intentional overwrites of the commit history, resulting in potential data loss, code inconsistencies, or the introduction of malicious changes. This compromises the stability and security of the repository.", - "RelatedUrl": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#allow-force-pushes", + "Description": "**GitHub repository default branch** blocks **force pushes** through branch protection.\n\nEvaluates whether the default branch permits force pushes.", + "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" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the repository and go to Settings\n2. In the sidebar, click Branches\n3. Edit the protection rule for the default branch (or Add rule with the default branch name)\n4. Ensure Allow force pushes is unchecked/disabled\n5. Click Save changes", + "Terraform": "```hcl\nresource \"github_branch_protection_v3\" \"\" {\n repository_id = \"\"\n pattern = \"\"\n\n allows_force_pushes = false # Critical: disallows force pushes on the default branch\n}\n```" }, "Recommendation": { - "Text": "Disable force pushes on protected branches to preserve the commit history and ensure the integrity of the repository. This measure helps prevent unintentional data loss and protects the repository from malicious changes.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "Text": "Disable `Allow force pushes` on the default branch. Enforce PR-based changes with required reviews and status checks, require signed commits and linear history, and restrict bypass to minimal actors. Apply protections to admins too to uphold **least privilege** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/repository_default_branch_disallows_force_push" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 dfa85e12a2..70668adbc1 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 @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_default_branch_protection_applies_to_admins", - "CheckTitle": "Check if repository enforces admin branch protection", + "CheckTitle": "Repository default branch protection applies to administrators", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GithubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that the repository enforces branch protection rules for administrators.", - "Risk": "Excluding administrators from branch protection rules introduces a significant risk of unauthorized or unreviewed changes being pushed to protected branches. This can lead to vulnerabilities, including the potential insertion of malicious code, especially if an administrator account is compromised.", - "RelatedUrl": "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", + "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).", + "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" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X POST /repos///branches//protection/enforce_admins", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, go to the repository > Settings > Branches\n2. Edit the branch protection rule that targets the default branch (or Add rule for )\n3. Enable: \"Do not allow bypassing the above settings\" (or \"Include administrators\")\n4. Click Save", + "Terraform": "```hcl\nresource \"github_branch_protection_v3\" \"\" {\n repository = \"\"\n branch = \"\"\n enforce_admins = true # Critical: applies branch protection to administrators\n}\n```" }, "Recommendation": { - "Text": "Enforce branch protection rules for administrators to ensure they adhere to the same security and quality standards as other users. This mitigates the risk of unreviewed or untrusted code being introduced, enhancing the overall integrity of the codebase.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "Text": "Enable **branch protection for administrators** and disallow bypasses. Apply **least privilege** and **separation of duties** by requiring PR reviews, required status checks, and signed commits on critical branches. Limit force pushes and deletions, and regularly review admin roles and audit logs.", + "Url": "https://hub.prowler.com/check/repository_default_branch_protection_applies_to_admins" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 38d4c51007..8f173e868f 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 @@ -1,30 +1,37 @@ { "Provider": "github", "CheckID": "repository_default_branch_protection_enabled", - "CheckTitle": "Check if branch protection is enforced on the default branch ", + "CheckTitle": "Repository enforces branch protection on the default branch", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", - "Severity": "critical", - "ResourceType": "GitHubRepository", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure branch protection is enforced on the default branch", - "Risk": "The absence of branch protection on the default branch increases the risk of unauthorized, unreviewed, or untested changes being merged. This can compromise the stability, security, and reliability of the codebase, which is especially critical for production deployments.", - "RelatedUrl": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches", + "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.", + "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" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PUT repos///branches//protection -f required_status_checks='null' -f required_pull_request_reviews='null' -f enforce_admins=false -f restrictions='null'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the repository and go to Settings\n2. Under \"Code and automation\", click Branches\n3. Click Add rule under \"Branch protection rules\"\n4. Set Branch name pattern to the default branch (e.g., main)\n5. Click Create to save the rule", + "Terraform": "```hcl\n# Enable branch protection on the default branch\n# Minimal: create a protection rule targeting the default branch\n\ndata \"github_repository\" \"repo\" {\n full_name = \"/\"\n}\n\nresource \"github_branch_protection_v3\" \"\" {\n repository_id = data.github_repository.repo.node_id\n pattern = \"\" # Critical: protects the default branch so the check passes\n}\n```" }, "Recommendation": { - "Text": "Apply branch protection rules to the default branch to ensure it is safeguarded against unauthorized or improper modifications. This helps maintain code quality, enforces proper review and testing procedures, and reduces the risk of accidental or malicious changes.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule#creating-a-branch-protection-rule" + "Text": "Enforce **branch protection** on the default branch:\n- Require pull requests with approvals (least privilege)\n- Enforce required status checks and conversation resolution\n- Require signed commits and linear history; block force pushes/deletions\n- Restrict push to trusted actors and apply rules to admins\nUse **CODEOWNERS** to strengthen review accountability.", + "Url": "https://hub.prowler.com/check/repository_default_branch_protection_enabled" } }, - "Categories": [], + "Categories": [ + "identity-access", + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 c127f22b0d..465e61aad9 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 @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_default_branch_requires_codeowners_review", - "CheckTitle": "Check if code owner approval is required for changes to owned code", + "CheckTitle": "Repository default branch requires code owner approval for changes to owned code", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GitHubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that code owners are required to review and approve any proposed changes that affect their respective areas of ownership in the code base.", - "Risk": "If code owner approval is not required, unauthorized or unqualified individuals may merge changes to sensitive or critical areas of the codebase, increasing the risk of security vulnerabilities, bugs, or malicious modifications.", - "RelatedUrl": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#requiring-code-owner-review", + "Description": "**GitHub repository default branch** requires **Code Owners** approval for pull requests that modify paths declared in `CODEOWNERS`", + "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" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PATCH repos///branches//protection/required_pull_request_reviews -f require_code_owner_reviews=true", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the repository and go to Settings > Branches\n2. Edit the branch protection rule for (or click Add rule)\n3. Set Branch name pattern to \n4. Check Require a pull request before merging\n5. Under Pull request reviews, check Require review from Code Owners\n6. Click Create/Save to apply", + "Terraform": "```hcl\nresource \"github_branch_protection\" \"\" {\n repository_id = \"\"\n pattern = \"\"\n\n required_pull_request_reviews {\n require_code_owner_reviews = true # Critical: enforces CODEOWNERS approval on the default branch\n }\n}\n```" }, "Recommendation": { - "Text": "To require code owner review, navigate to the repository settings, click on 'Branches', add or edit a branch protection rule, and enable 'Require review from Code Owners'.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-review-from-code-owners" + "Text": "Enforce **Code Owners** review on the default branch and keep `CODEOWNERS` accurate and team-based.\nApply **least privilege**, require **status checks** and **signed commits**, extend protections to admins, and audit ownership regularly. Co-own critical paths to ensure coverage and reduce single-point approval gaps.", + "Url": "https://hub.prowler.com/check/repository_default_branch_requires_codeowners_review" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 6a0e66c3d1..beaff24afb 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 @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_default_branch_requires_conversation_resolution", - "CheckTitle": "Check if repository requires conversation resolution before merging", + "CheckTitle": "Repository default branch requires conversation resolution before merging", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "GitHubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that the repository requires conversation resolution before merging.", - "Risk": "Leaving comments unresolved before merging code can lead to overlooked issues, including potential bugs or security vulnerabilities, that might affect the quality and security of the codebase. Unaddressed concerns could result in a lower quality of code, increasing the risk of production failures or breaches.", - "RelatedUrl": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#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`).", + "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" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PUT repos///branches//protection/required_conversation_resolution -f enabled=true", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, go to the repository > Settings\n2. Click Branches\n3. Edit the branch protection rule for the default branch (e.g., main)\n4. Check \"Require conversation resolution before merging\"\n5. Click Save changes", + "Terraform": "```hcl\nresource \"github_branch_protection\" \"\" {\n repository_id = \"\"\n pattern = \"\"\n\n require_conversation_resolution = true # Critical: require all PR conversations to be resolved before merge\n}\n```" }, "Recommendation": { - "Text": "Ensure that all comments in a code change proposal are resolved before merging. This guarantees that every reviewer’s concern is addressed, improving code quality and security by preventing issues from being ignored or overlooked.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "Text": "Enable `Require conversation resolution before merging` on the default branch.\n\nAlso enforce:\n- Required approvals and CI checks\n- **CODEOWNERS** on critical paths\n- **Least privilege** for merge rights\n- Apply rules to admins to prevent bypass", + "Url": "https://hub.prowler.com/check/repository_default_branch_requires_conversation_resolution" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 da87dbd94f..215ab496aa 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 @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_default_branch_requires_linear_history", - "CheckTitle": "Check if repository default branch requires linear history", + "CheckTitle": "Repository default branch requires linear history", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "GithubRepository", + "Severity": "low", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that the repository default branch requires linear history.", - "Risk": "Allowing non-linear history can result in a cluttered and difficult-to-trace Git history, making it harder to identify specific changes, debug issues, and understand the sequence of development. This increases the risk of errors, inconsistencies, and bugs, especially in production environments.", - "RelatedUrl": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-linear-history", + "Description": "**GitHub repository default branch** enforces `Require linear history`, blocking merge commits and allowing only `squash` or `rebase` merges", + "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" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "curl -X PUT -H \"Authorization: Bearer \" -H \"Accept: application/vnd.github+json\" -H \"Content-Type: application/json\" https://api.github.com/repos///branches//protection -d '{\"required_status_checks\":null,\"enforce_admins\":false,\"required_pull_request_reviews\":null,\"restrictions\":null,\"required_linear_history\":true}'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, go to the repository > Settings > Branches\n2. If a rule exists for the default branch, click Edit; otherwise click Add rule and set Branch name pattern to the default branch (e.g., main)\n3. Check Require linear history\n4. Click Create (or Save changes)", + "Terraform": "```hcl\nresource \"github_branch_protection\" \"\" {\n repository_id = \"\"\n pattern = \"\"\n require_linear_history = true # Critical: enforces linear history on the default branch\n}\n```" }, "Recommendation": { - "Text": "Enforce a linear history by requiring rebase or squash merges for pull requests. This will create a clean, chronological commit history, making it easier to track changes, revert modifications, and troubleshoot any issues that arise.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "Text": "Enable `Require linear history` on the default branch and allow only `squash` or `rebase` merges.\n\nReinforce with **branch protection**: require pull requests and reviews, **status checks**, and **signed commits**. Limit bypass to trusted roles (least privilege) to preserve **traceability** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/repository_default_branch_requires_linear_history" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 eb9e6d5273..dda4f25ea0 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 @@ -1,30 +1,37 @@ { "Provider": "github", "CheckID": "repository_default_branch_requires_multiple_approvals", - "CheckTitle": "Check if repositories require at least 2 code changes approvals", + "CheckTitle": "Repository default branch requires at least 2 approvals for code changes", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", - "Severity": "high", - "ResourceType": "GitHubRepository", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that repositories require at least 2 code changes approvals before merging a pull request.", - "Risk": "If repositories do not require at least 2 code changes approvals before merging a pull request, it is possible that code changes are not being reviewed by multiple people, which could lead to the introduction of bugs or security vulnerabilities.", - "RelatedUrl": "https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/approving-a-pull-request-with-required-reviews", + "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.", + "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" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PUT repos///branches//protection -F required_status_checks=null -F enforce_admins=false -F restrictions=null -F required_pull_request_reviews.required_approving_review_count=2", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the repository and go to Settings > Branches\n2. Under Branch protection rules, click Add rule (or Edit for the default branch rule)\n3. Set Branch name pattern to the default branch (e.g., main)\n4. Check Require a pull request before merging\n5. Set Require approvals to 2\n6. Click Create/Save to apply", + "Terraform": "```hcl\nresource \"github_branch_protection\" \"\" {\n repository_id = \"\"\n pattern = \"\"\n\n required_pull_request_reviews {\n required_approving_review_count = 2 # Enforces at least 2 approvals before merging into the default branch\n }\n}\n```" }, "Recommendation": { - "Text": "To require at least 2 code changes approvals before merging a pull request, navigate to the repository settings, click on 'Branches', and then 'Add rule'.", - "Url": "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" + "Text": "Enforce the **four-eyes principle** by requiring at least `2` approvals for merges to the default branch.\n\nStrengthen with **separation of duties** using code owner reviews, dismiss stale approvals, apply protections to admins, and pair with required status checks for **defense in depth**.", + "Url": "https://hub.prowler.com/check/repository_default_branch_requires_multiple_approvals" } }, - "Categories": [], + "Categories": [ + "ci-cd", + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 528e947cd9..d3d9c91806 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 @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_default_branch_requires_signed_commits", - "CheckTitle": "Check if repository requires signed commits", + "CheckTitle": "Repository default branch requires signed commits", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", - "Severity": "medium", - "ResourceType": "GitHubRepository", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that every commit in a pull request is signed and verified before merging to the default branch.", - "Risk": "If repositories do not require signed commits, there is no way to verify the authenticity and integrity of code changes. This could allow malicious actors to impersonate legitimate contributors and introduce unauthorized or harmful changes to the codebase.", - "RelatedUrl": "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification", + "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.", + "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" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X POST repos///branches//protection/required_signatures", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, go to the repository > Settings\n2. Click Branches\n3. Edit the rule for the default branch (or Add rule targeting the default branch name)\n4. Check Require signed commits\n5. Click Save changes", + "Terraform": "```hcl\nresource \"github_branch_protection_v3\" \"\" {\n repository = \"\"\n pattern = \"\"\n\n required_signatures = true # Critical: Enforces signed commits on the default branch\n}\n```" }, "Recommendation": { - "Text": "Enable the 'Require signed commits' option in branch protection rules to ensure that all commits are cryptographically signed and verified before they can be merged.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-signed-commits" + "Text": "Enforce `Require signed commits` on default and release branches.\n- Standardize GPG/SSH/S/MIME for humans and bots\n- Protect and rotate signing keys; least privilege on bypass\n- Pair with required reviews and status checks for defense-in-depth", + "Url": "https://hub.prowler.com/check/repository_default_branch_requires_signed_commits" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 2e8d91891a..6d450fde33 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 @@ -1,30 +1,37 @@ { "Provider": "github", "CheckID": "repository_default_branch_status_checks_required", - "CheckTitle": "Check if repository enforces status checks to pass", + "CheckTitle": "Repository default branch requires status checks", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GithubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that the repository enforces status checks to pass before merging code into the main branch.", - "Risk": "Merging code without requiring all checks to pass increases the risk of introducing bugs, vulnerabilities, or unstable changes into the codebase. This can compromise the quality, security, and functionality of the application.", - "RelatedUrl": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging", + "Description": "**GitHub repository default branch** uses **required status checks**, indicating whether merges are gated by successful check results on pull requests", + "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" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PATCH repos///branches//protection/required_status_checks -f strict=false -F contexts[]=", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, go to the repository > Settings > Branches\n2. Next to Branch protection rules, click Add rule (or Edit for the default branch rule)\n3. Set Branch name pattern to your default branch (e.g., main)\n4. Check Require status checks to pass before merging\n5. In the list, select at least one check (e.g., your CI workflow)\n6. Click Create (or Save changes)", + "Terraform": "```hcl\nresource \"github_branch_protection_v3\" \"\" {\n repository_id = \"\"\n pattern = \"\"\n\n required_status_checks {\n strict = false\n contexts = [\"\"] # Critical: requires this status check to pass before merging\n }\n}\n```" }, "Recommendation": { - "Text": "Require all predefined status checks to pass successfully before allowing code changes to be merged. This ensures that all quality, stability, and security conditions are met, reducing the likelihood of errors or vulnerabilities being introduced into the project.", - "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/managing-a-branch-protection-rule" + "Text": "Enforce branch protection to require **status checks** on the default branch.\n- Gate merges on build, tests, and security scans\n- Use `require up-to-date` behavior to reduce integration risk\n- Apply to admins and limit bypasses\n- Combine with **least privilege** and required reviews for defense in depth", + "Url": "https://hub.prowler.com/check/repository_default_branch_status_checks_required" } }, - "Categories": [], + "Categories": [ + "ci-cd" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json index 2f1db16b79..0b3e655e9f 100644 --- a/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json +++ b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_dependency_scanning_enabled", - "CheckTitle": "Check if package vulnerability scanning is enabled for dependencies in the repository", + "CheckTitle": "Repository has package vulnerability scanning (Dependabot alerts) enabled", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GitHubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Implement scanning tools to detect, prevent, and monitor known open-source vulnerabilities in packages used within the organization's projects. This check verifies that dependency/package vulnerability scanning (e.g., Dependabot alerts) is enabled for the repository.", - "Risk": "If package vulnerability scanning is not enabled, known vulnerabilities in dependencies may go undetected, increasing the risk of exploitation and security breaches.", - "RelatedUrl": "https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts", + "Description": "**GitHub repositories** are assessed for **dependency vulnerability scanning** enabled via `Dependabot alerts`, which monitors the dependency graph for known vulnerable packages and versions.", + "Risk": "Without automated scanning, known vulnerable dependencies may persist unnoticed, enabling supply-chain compromise. Exploits in third-party libraries can drive RCE, data theft, or build tampering, undermining confidentiality, integrity, and availability across code and CI/CD.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api --method PUT repos///vulnerability-alerts", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the target repository\n2. Click Settings > Code security and analysis\n3. Under Dependabot alerts, click Enable\n4. Verify the toggle shows Enabled", + "Terraform": "```hcl\nresource \"github_repository\" \"\" {\n name = \"\"\n vulnerability_alerts = true # Enables Dependabot alerts to pass the check\n}\n```" }, "Recommendation": { - "Text": "Enable Dependabot alerts or another package vulnerability scanner in the repository settings to automatically detect and alert on vulnerable dependencies.", - "Url": "https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts" + "Text": "Enable **dependency vulnerability scanning** with `Dependabot alerts` or an equivalent SCA tool across repositories. Apply **defense in depth**: keep manifests and lockfiles current, triage alerts quickly, use automated security updates with required reviews, and surface results in PR checks and notifications.", + "Url": "https://hub.prowler.com/check/repository_dependency_scanning_enabled" } }, - "Categories": [], + "Categories": [ + "vulnerabilities", + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json index a1f8af793e..4568c6100d 100644 --- a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json +++ b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_has_codeowners_file", - "CheckTitle": "Check if repositories have a CODEOWNERS file", + "CheckTitle": "Repository has a CODEOWNERS file", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", - "Severity": "high", - "ResourceType": "GitHubRepository", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that repositories have a CODEOWNERS file.", - "Risk": "Not having a CODEOWNERS file in a repository may lead to unclear code ownership and review responsibilities, increasing the risk of unreviewed or unauthorized changes.", - "RelatedUrl": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners", + "Description": "GitHub repositories declare **code ownership** via a `CODEOWNERS` file mapping file patterns to users or teams. This evaluation checks whether such a file exists in standard locations (`/`, `.github/`, or `docs/`) to enable automatic reviewer assignment.", + "Risk": "Missing `CODEOWNERS` undermines **integrity** and **separation of duties**:\n- PRs can merge without accountable, domain reviews\n- Critical paths risk unauthorized or low-quality changes\nThis raises **software supply chain** exposure, enabling code tampering, hidden backdoors, and unsafe config modifications.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners", + "https://github.blog/news-insights/product-news/introducing-code-owners/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PUT repos///contents/.github/CODEOWNERS -f message='add CODEOWNERS' -f content=\"$(printf '* @\\n' | base64 | tr -d '\\n')\"", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the target repository\n2. Click Add file > Create new file\n3. Set the filename to .github/CODEOWNERS\n4. Add a single line: `* @`\n5. Click Commit new file (commit to the default branch)", + "Terraform": "```hcl\nresource \"github_repository_file\" \"\" {\n repository = \"\"\n file = \".github/CODEOWNERS\" # Critical: creates the CODEOWNERS file so the check passes\n content = \"* @\" # Critical: minimal valid content so the file is recognized\n}\n```" }, "Recommendation": { - "Text": "Add a CODEOWNERS file to the root, .github/, or docs/ directory of the repository. The file should specify code owners for files and directories as appropriate for your organization.", - "Url": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners" + "Text": "Define and maintain a `CODEOWNERS` file mapping sensitive paths to responsible teams or users, preferring teams for resilience. Combine with branch protections requiring code-owner reviews to enforce **separation of duties** and **least privilege**. Keep entries current and cover critical directories to avoid pattern gaps.", + "Url": "https://hub.prowler.com/check/repository_has_codeowners_file" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.metadata.json b/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.metadata.json index 6538ebe009..5f0742745c 100644 --- a/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.metadata.json +++ b/prowler/providers/github/services/repository/repository_immutable_releases_enabled/repository_immutable_releases_enabled.metadata.json @@ -5,23 +5,26 @@ "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GitHubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Immutable releases prevent modification or replacement of published artifacts after publication. When enabled, release assets and binaries become tamper-proof, ensuring artifact integrity throughout the software supply chain.", + "Description": "**GitHub repository** has **immutable releases** enabled to prevent modification or replacement of published artifacts after publication. When enabled, release assets and binaries become tamper-proof, ensuring artifact integrity throughout the software supply chain.", "Risk": "If immutable releases are disabled, release assets can be tampered with after publication, allowing attackers to substitute malicious binaries and undermining supply chain integrity.", - "RelatedUrl": "https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#preventing-changes-to-releases", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository#preventing-changes-to-releases" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the repository and go to Settings > Code and automation > Releases\n2. Under \"Immutable releases\", toggle the switch to On\n3. Click Save", + "Terraform": "```hcl\nresource \"github_repository\" \"\" {\n name = \"\"\n immutable_releases = true # Enables immutable releases so the check passes\n}\n```" }, "Recommendation": { - "Text": "Enable immutable releases in the repository settings so release artifacts cannot be altered once published.", - "Url": "https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases" + "Text": "Enable **immutable releases** in the repository settings so release artifacts cannot be altered once published.", + "Url": "https://hub.prowler.com/check/repository_immutable_releases_enabled" } }, "Categories": [ diff --git a/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json index edc74160c6..ebe769dfd4 100644 --- a/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json +++ b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json @@ -1,30 +1,35 @@ { "Provider": "github", "CheckID": "repository_inactive_not_archived", - "CheckTitle": "Check for inactive repositories that are not archived", + "CheckTitle": "Repository is archived or active within the configured inactivity threshold", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "GitHubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that repositories with no activity are reviewed and considered for archival. Inactive repositories may have outdated dependencies or security configurations that could pose security risks.", - "Risk": "Inactive repositories that are not archived may contain outdated dependencies, unpatched vulnerabilities, or misconfigured security settings. These repositories increase the attack surface and could be targeted by malicious actors.", - "RelatedUrl": "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories", + "Description": "**GitHub repository** that remains **unarchived** and show no activity beyond a configured inactivity window (e.g., `180` days) are identified. The evaluation considers the most recent repository activity to surface long-idle codebases that are still unarchived.", + "Risk": "Unarchived, long-inactive repos expand attack surface. Stale code and dependencies can hide unpatched flaws; writable state enables **integrity** compromise via malicious commits or workflow abuse; exposed secrets threaten **confidentiality**. Attackers can leverage them for lateral movement and supply-chain tampering.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh repo archive / --yes", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Open the repository on GitHub\n2. Click Settings\n3. Scroll to Danger Zone\n4. Click Archive this repository\n5. Type the repository name and confirm by clicking I understand the consequences, archive this repository", + "Terraform": "```hcl\nresource \"github_repository\" \"repo\" {\n name = \"\"\n archived = true # Critical: archives the repository so inactive repos pass the check\n}\n```" }, "Recommendation": { - "Text": "Review inactive repositories and either: 1) Archive them if they are no longer needed, 2) Update their dependencies and security configurations if they are still required, or 3) Delete them if they contain no valuable information.", - "Url": "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories" + "Text": "Adopt **lifecycle management**:\n- Archive or delete repos no longer needed\n- If retained, update dependencies, rotate secrets, disable unused workflows, and restrict writes under **least privilege**\n- Define an inactivity policy (e.g., `180` days) with periodic reviews to prevent dormant, writable codebases", + "Url": "https://hub.prowler.com/check/repository_inactive_not_archived" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.metadata.json b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.metadata.json index 6eba45c102..bafa2c2b6a 100644 --- a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.metadata.json +++ b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.metadata.json @@ -1,30 +1,36 @@ { "Provider": "github", "CheckID": "repository_public_has_securitymd_file", - "CheckTitle": "Check if public repositories have a SECURITY.md file", + "CheckTitle": "Public repository has a SECURITY.md file", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", + "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "GitHubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that public repositories have a SECURITY.md file", - "Risk": "Not having a SECURITY.md file in a public repository may lead to security vulnerabilities being overlooked by users and contributors.", - "RelatedUrl": "https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository", + "Description": "**GitHub public repository** include a `SECURITY.md` policy file that tells researchers how to report vulnerabilities. The evaluation focuses on the presence of this file in public repositories.", + "Risk": "Without **SECURITY.md**, reporters may use public issues or ad-hoc channels, causing premature disclosure or missed reports. This widens the exploit window and impacts **confidentiality** (leaked details), **integrity** (unauthorized changes), and **availability** (DoS from unpatched flaws).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository", + "https://github.blog/changelog/2019-05-23-security-policy/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PUT repos///contents/.github/SECURITY.md -f message='Add SECURITY.md' -f content='UmVwb3J0IHZ1bG5lcmFiaWxpdGllcyB0byBzZWN1cml0eUBleGFtcGxlLmNvbQo='", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the target repository\n2. Click Security > Policy (under Reporting)\n3. Click Start setup\n4. Add minimal instructions (e.g., how to report vulnerabilities)\n5. Click Commit changes to create SECURITY.md", + "Terraform": "```hcl\nresource \"github_repository_file\" \"security_md\" {\n repository = \"\"\n file = \".github/SECURITY.md\" # Critical: ensures SECURITY.md exists in a recognized location\n content = \"Report vulnerabilities to security@example.com\" # Critical: creates the file so the check passes\n}\n```" }, "Recommendation": { - "Text": "Add a SECURITY.md file to the root of the repository. The file should contain information on how to report a security vulnerability, the security policy of the repository, and any other relevant information.", - "Url": "https://github.blog/changelog/2019-05-23-security-policy/" + "Text": "Publish a clear `SECURITY.md` for each public repo or an org default. Include private reporting channels, optional encryption keys, scope/supported versions, disclosure timelines, and safe-harbor terms. Link to any bounty program and review regularly. Align with **accountability** and **defense in depth** principles.", + "Url": "https://hub.prowler.com/check/repository_public_has_securitymd_file" } }, - "Categories": [], + "Categories": [ + "software-supply-chain" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json index 085caaefbc..a923535386 100644 --- a/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json +++ b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "github", "CheckID": "repository_secret_scanning_enabled", - "CheckTitle": "Check if secret scanning is enabled to detect sensitive data in the repository", + "CheckTitle": "Repository has secret scanning enabled to detect sensitive data", "CheckType": [], "ServiceName": "repository", "SubServiceName": "", - "ResourceIdTemplate": "github:user-id:repository/repository-name", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "GitHubRepository", + "ResourceType": "NotDefined", "ResourceGroup": "devops", - "Description": "Ensure that scanners are in place to detect and prevent sensitive data, such as confidential ID numbers, passwords, and other sensitive information, from being committed in the source code. This check verifies that secret scanning is enabled to identify and prevent sensitive data from being included in the repository.", - "Risk": "If secret scanning is not enabled, sensitive data may be inadvertently committed to the repository, increasing the risk of data breaches and exploitation by attackers.", - "RelatedUrl": "https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning", + "Description": "**GitHub repository** configuration for **secret scanning**-which detects secrets (API keys, tokens, passwords) in commits and Git history-is evaluated to determine if detection is active.", + "Risk": "Without **secret scanning**, exposed credentials can persist unnoticed, enabling:\n- Unauthorized access to cloud and third-party services\n- **Supply-chain compromise** via tampered pipelines\n- Data exfiltration and repo takeover\n\nThis degrades **confidentiality** and **integrity**, and increases blast radius of a single leaked key.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "gh api -X PATCH repos// -H \"Accept: application/vnd.github+json\" -H \"Content-Type: application/json\" -d '{\"security_and_analysis\":{\"secret_scanning\":{\"status\":\"enabled\"}}}'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In GitHub, open the target repository and go to Settings\n2. In the left sidebar, click Code security and analysis\n3. Under Secret scanning, click Enable (or set to Enabled)\n4. Confirm if prompted", + "Terraform": "```hcl\nresource \"github_repository\" \"\" {\n name = \"\"\n\n security_and_analysis {\n secret_scanning { # Critical: enable secret scanning\n status = \"enabled\" # Turns on secret scanning for the repository\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Enable secret scanning in the repository settings to automatically detect and prevent sensitive data from being committed to the codebase.", - "Url": "https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning" + "Text": "Enable **secret scanning** (and **push protection** where available) across repositories.\n- Store secrets in a dedicated **secrets manager**, never in code\n- Define custom patterns and enable generic detection for org-specific secrets\n- Rotate and revoke exposed credentials quickly\n- Enforce **least privilege** and add **defense-in-depth** monitoring", + "Url": "https://hub.prowler.com/check/repository_secret_scanning_enabled" } }, - "Categories": [], + "Categories": [ + "secrets" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/googleworkspace/exceptions/exceptions.py b/prowler/providers/googleworkspace/exceptions/exceptions.py index f38f6481c2..9e4cd2fea7 100644 --- a/prowler/providers/googleworkspace/exceptions/exceptions.py +++ b/prowler/providers/googleworkspace/exceptions/exceptions.py @@ -38,6 +38,10 @@ class GoogleWorkspaceBaseException(ProwlerException): "message": "Service Account does not have required OAuth scopes", "remediation": "Ensure the Service Account has the required scopes configured in Domain-Wide Delegation settings.", }, + (12008, "GoogleWorkspaceInvalidProviderIdError"): { + "message": "The provided provider_id does not match the credentials customer ID", + "remediation": "Check the provider_id (Customer ID) and ensure it matches the Google Workspace organization for the given credentials.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -117,3 +121,10 @@ class GoogleWorkspaceInsufficientScopesError(GoogleWorkspaceCredentialsError): super().__init__( 12007, file=file, original_exception=original_exception, message=message ) + + +class GoogleWorkspaceInvalidProviderIdError(GoogleWorkspaceBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 12008, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/googleworkspace/googleworkspace_provider.py b/prowler/providers/googleworkspace/googleworkspace_provider.py index 61f85f2f24..83beee0d3e 100644 --- a/prowler/providers/googleworkspace/googleworkspace_provider.py +++ b/prowler/providers/googleworkspace/googleworkspace_provider.py @@ -21,6 +21,7 @@ from prowler.providers.googleworkspace.exceptions.exceptions import ( GoogleWorkspaceImpersonationError, GoogleWorkspaceInsufficientScopesError, GoogleWorkspaceInvalidCredentialsError, + GoogleWorkspaceInvalidProviderIdError, GoogleWorkspaceMissingDelegatedUserError, GoogleWorkspaceNoCredentialsError, GoogleWorkspaceSetUpIdentityError, @@ -479,6 +480,7 @@ class GoogleworkspaceProvider(Provider): credentials_content: str = None, delegated_user: str = None, raise_on_exception: bool = True, + provider_id: str = None, ) -> Connection: """Test connection to Google Workspace. @@ -489,6 +491,7 @@ class GoogleworkspaceProvider(Provider): credentials_content (str): Service Account JSON credentials as a string. delegated_user (str): Email of the user to impersonate via Domain-Wide Delegation. raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails. + provider_id (str): The provider ID (Customer ID). Optional, not used in connection test. Returns: Connection: Connection object with success status or error information. @@ -515,7 +518,16 @@ class GoogleworkspaceProvider(Provider): ) # Set up the identity to test the connection - GoogleworkspaceProvider.setup_identity(session, resolved_delegated_user) + identity = GoogleworkspaceProvider.setup_identity( + session, resolved_delegated_user + ) + + # Validate provider_id matches the customer_id from credentials + if provider_id and provider_id != identity.customer_id: + raise GoogleWorkspaceInvalidProviderIdError( + file=os.path.basename(__file__), + message=f"The provider ID {provider_id} does not match the credentials customer ID {identity.customer_id}", + ) return Connection(is_connected=True) except Exception as error: diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index e7b83995a7..5b3d898fb3 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -224,6 +224,7 @@ class IacProvider(Provider): }, }, "Categories": [], + "AdditionalURLs": [], "DependsOn": [], "RelatedTo": [], "Notes": "", diff --git a/prowler/providers/iac/lib/arguments/arguments.py b/prowler/providers/iac/lib/arguments/arguments.py index 242138145e..a489eb3063 100644 --- a/prowler/providers/iac/lib/arguments/arguments.py +++ b/prowler/providers/iac/lib/arguments/arguments.py @@ -1,5 +1,7 @@ import re +SENSITIVE_ARGUMENTS = frozenset({"--personal-access-token", "--oauth-app-token"}) + SCANNERS_CHOICES = [ "vuln", "misconfig", @@ -74,7 +76,7 @@ def init_parser(self): "--provider-uid", dest="provider_uid", default=None, - help="Unique identifier for the IaC provider. Required when using --export-ocsf.", + help="Unique identifier for the IaC provider. Required when using --push-to-cloud.", ) @@ -88,12 +90,12 @@ def validate_arguments(arguments): False, "--scan-path (-P) and --scan-repository-url (-R) are mutually exclusive. Please specify only one.", ) - export_ocsf = getattr(arguments, "export_ocsf", False) + push_to_cloud = getattr(arguments, "push_to_cloud", False) provider_uid = getattr(arguments, "provider_uid", None) - if export_ocsf and not provider_uid: + if push_to_cloud and not provider_uid: return ( False, - "--provider-uid is required when using --export-ocsf with the IAC provider.", + "--provider-uid is required when using --push-to-cloud with the IAC provider.", ) if provider_uid and not re.match( r"^(https?://|git@|ssh://)[^\s/]+[^\s]*\.git$|^(https?://)[^\s/]+[^\s]*$", diff --git a/prowler/providers/image/image_provider.py b/prowler/providers/image/image_provider.py index 9817656dd2..d724542e28 100644 --- a/prowler/providers/image/image_provider.py +++ b/prowler/providers/image/image_provider.py @@ -5,6 +5,7 @@ import os import re import subprocess import sys +import tempfile from typing import Generator from alive_progress import alive_bar @@ -88,7 +89,9 @@ class ImageProvider(Provider): self.images = images if images is not None else [] self.image_list_file = image_list_file - self.scanners = scanners if scanners is not None else ["vuln", "secret"] + self.scanners = ( + scanners if scanners is not None else ["vuln", "secret", "misconfig"] + ) self.image_config_scanners = ( image_config_scanners if image_config_scanners is not None else [] ) @@ -100,6 +103,10 @@ class ImageProvider(Provider): self._session = None self._identity = "prowler" self._listing_only = False + self._trivy_cache_dir_obj = tempfile.TemporaryDirectory( + prefix="prowler-trivy-cache-" + ) + self._trivy_cache_dir = self._trivy_cache_dir_obj.name # Registry authentication (follows IaC pattern: explicit params, env vars internal) self.registry_username = registry_username or os.environ.get( @@ -329,9 +336,15 @@ class ImageProvider(Provider): def _is_registry_url(image_uid: str) -> bool: """Determine whether an image UID is a registry URL (namespace only). - A registry URL like ``docker.io/andoniaf`` has a registry host but - the remaining part contains no ``/`` (no repo) and no ``:`` (no tag). + Bare hostnames like "714274078102.dkr.ecr.eu-west-1.amazonaws.com" + or "myregistry.com:5000" are registry URLs (dots in host, no slash). + Image references like "alpine:3.18" or "nginx" are not. """ + if "/" not in image_uid: + host_part = image_uid.split(":")[0] + if "." in host_part: + return True + registry_host = ImageProvider._extract_registry(image_uid) if not registry_host: return False @@ -340,6 +353,8 @@ class ImageProvider(Provider): def cleanup(self) -> None: """Clean up any resources after scanning.""" + if hasattr(self, "_trivy_cache_dir_obj"): + self._trivy_cache_dir_obj.cleanup() def _process_finding( self, @@ -368,7 +383,7 @@ class ImageProvider(Provider): "Description", finding.get("Title", "") ) finding_status = "FAIL" - finding_categories = ["vulnerability"] + finding_categories = ["vulnerabilities"] elif "RuleID" in finding: # Secret finding finding_id = finding["RuleID"] @@ -418,10 +433,13 @@ class ImageProvider(Provider): }, "Recommendation": { "Text": remediation_text, - "Url": finding.get("PrimaryURL", ""), + "Url": "", }, }, "Categories": finding_categories, + "AdditionalURLs": ( + [url] if (url := finding.get("PrimaryURL", "")) else [] + ), "DependsOn": [], "RelatedTo": [], "Notes": "", @@ -540,6 +558,8 @@ class ImageProvider(Provider): trivy_command = [ "trivy", "image", + "--cache-dir", + self._trivy_cache_dir, "--format", "json", "--scanners", @@ -928,6 +948,9 @@ class ImageProvider(Provider): Uses registry HTTP APIs directly instead of Trivy to avoid false failures caused by Trivy DB download issues. + For bare registry hostnames (e.g. ECR URLs passed by the API as provider_uid), + uses the OCI catalog endpoint instead of trivy image. + Args: image: Container image or registry URL to test raise_on_exception: Whether to raise exceptions @@ -946,32 +969,34 @@ class ImageProvider(Provider): if not image: return Connection(is_connected=False, error="Image name is required") + # Registry URL (bare hostname) → test via OCI catalog if ImageProvider._is_registry_url(image): - # Registry enumeration mode — test by listing repositories - adapter = create_registry_adapter( + return ImageProvider._test_registry_connection( registry_url=image, - username=registry_username, - password=registry_password, - token=registry_token, + registry_username=registry_username, + registry_password=registry_password, + registry_token=registry_token, ) - adapter.list_repositories() - return Connection(is_connected=True) - # Image reference mode — verify the specific tag exists + # Image reference → verify tag exists via registry API registry_host = ImageProvider._extract_registry(image) - repo_and_tag = image[len(registry_host) + 1 :] if registry_host else image - if ":" in repo_and_tag: - repository, tag = repo_and_tag.rsplit(":", 1) - else: - repository = repo_and_tag - tag = "latest" - - is_dockerhub = not registry_host or registry_host in ( + is_dockerhub = registry_host is None or registry_host in ( "docker.io", "registry-1.docker.io", ) - # Docker Hub official images use "library/" prefix + # Parse repository and tag from the image reference + ref = image.rsplit("@", 1)[0] if "@" in image else image + last_segment = ref.split("/")[-1] + if ":" in last_segment: + tag = last_segment.split(":")[-1] + base = ref[: -(len(tag) + 1)] + else: + tag = "latest" + base = ref + + repository = base[len(registry_host) + 1 :] if registry_host else base + if is_dockerhub and "/" not in repository: repository = f"library/{repository}" @@ -1013,3 +1038,37 @@ class ImageProvider(Provider): is_connected=False, error=f"Unexpected error: {str(error)}", ) + + @staticmethod + def _test_registry_connection( + registry_url: str, + registry_username: str | None = None, + registry_password: str | None = None, + registry_token: str | None = None, + ) -> "Connection": + """Test connection to a registry URL by listing repositories via OCI catalog.""" + try: + adapter = create_registry_adapter( + registry_url=registry_url, + username=registry_username, + password=registry_password, + token=registry_token, + ) + adapter.list_repositories() + return Connection(is_connected=True) + except Exception as error: + error_str = str(error).lower() + if "401" in error_str or "unauthorized" in error_str: + return Connection( + is_connected=False, + error="Authentication failed. Check registry credentials.", + ) + elif "404" in error_str or "not found" in error_str: + return Connection( + is_connected=False, + error="Registry catalog not found.", + ) + return Connection( + is_connected=False, + error=f"Failed to connect to registry: {str(error)[:200]}", + ) diff --git a/prowler/providers/image/lib/arguments/arguments.py b/prowler/providers/image/lib/arguments/arguments.py index 3dfe9cf92b..529061befc 100644 --- a/prowler/providers/image/lib/arguments/arguments.py +++ b/prowler/providers/image/lib/arguments/arguments.py @@ -50,9 +50,9 @@ def init_parser(self): "--scanner", dest="scanners", nargs="+", - default=["vuln", "secret"], + default=["vuln", "secret", "misconfig"], choices=SCANNERS_CHOICES, - help="Trivy scanners to use. Default: vuln, secret. Available: vuln, secret, misconfig, license", + help="Trivy scanners to use. Default: vuln, secret, misconfig. Available: vuln, secret, misconfig, license", ) scan_config_group.add_argument( diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_always_pull_images_plugin/apiserver_always_pull_images_plugin.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_always_pull_images_plugin/apiserver_always_pull_images_plugin.metadata.json index bad10e7e7e..cd40ab9340 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_always_pull_images_plugin/apiserver_always_pull_images_plugin.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_always_pull_images_plugin/apiserver_always_pull_images_plugin.metadata.json @@ -1,27 +1,33 @@ { "Provider": "kubernetes", "CheckID": "apiserver_always_pull_images_plugin", - "CheckTitle": "Ensure that the admission control plugin AlwaysPullImages is set", + "CheckTitle": "API server pod has AlwaysPullImages admission control plugin enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the AlwaysPullImages admission control plugin is enabled in the Kubernetes API server. This plugin ensures that every new pod always pulls the required images, enforcing image access control and preventing the use of possibly outdated or altered images.", - "Risk": "Without AlwaysPullImages, once an image is pulled to a node, any pod can use it without any authorization check, potentially leading to security risks.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwayspullimages", + "Description": "**Kubernetes API server** admission configuration includes **AlwaysPullImages**, which mutates new Pods to set `imagePullPolicy=Always` so container images are fetched from the registry at startup using the pod's credentials.", + "Risk": "Without **AlwaysPullImages**, nodes can run cached images without a fresh registry pull, bypassing credential checks.\n- Unauthorized use of private images (confidentiality)\n- Stale or tampered images deployed (integrity)\n- Vulnerable images persist, widening attack surface (availability)", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cjyabraham.gitlab.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers", + "https://blog.codefarm.me/2021/12/15/kubernetes-admission-controllers/", + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwayspullimages" + ], "Remediation": { "Code": { - "CLI": "--enable-admission-plugins=...,AlwaysPullImages,...", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-alwayspullimages-is-set#kubernetes", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to a control-plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command or args, ensure the flag includes AlwaysPullImages, e.g.: --enable-admission-plugins=,AlwaysPullImages\n4. Save the file; the kubelet will automatically restart the API server with the updated flag", "Terraform": "" }, "Recommendation": { - "Text": "Configure the API server to use the AlwaysPullImages admission control plugin to ensure image security and integrity.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers" + "Text": "Enable `AlwaysPullImages` on the API server.\n\nApply defense in depth: restrict pulls to trusted registries, enforce least-privilege image pull secrets, sign and scan images, and prefer immutable digests to prevent drift and ensure verified content.", + "Url": "https://hub.prowler.com/check/apiserver_always_pull_images_plugin" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_anonymous_requests/apiserver_anonymous_requests.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_anonymous_requests/apiserver_anonymous_requests.metadata.json index 525bfceb51..879d41e559 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_anonymous_requests/apiserver_anonymous_requests.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_anonymous_requests/apiserver_anonymous_requests.metadata.json @@ -1,31 +1,37 @@ { "Provider": "kubernetes", "CheckID": "apiserver_anonymous_requests", - "CheckTitle": "Ensure that the --anonymous-auth argument is set to false", + "CheckTitle": "API server pod has anonymous-auth disabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "Disable anonymous requests to the API server. When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests, which are then served by the API server. Disallowing anonymous requests strengthens security by ensuring all access is authenticated.", - "Risk": "Enabling anonymous access to the API server can expose the cluster to unauthorized access and potential security vulnerabilities.", - "RelatedUrl": "https://kubernetes.io/docs/admin/authentication/#anonymous-requests", + "Description": "**Kubernetes API server** anonymous authentication configuration, identified by `--anonymous-auth=true`. With this setting, unauthenticated requests are mapped to `system:anonymous` and processed by the server.", + "Risk": "**Anonymous API access** exposes cluster details for **reconnaissance** and enumeration, eroding confidentiality.\n\nIf **RBAC** is misconfigured, unauthenticated users may read sensitive data or trigger actions, impacting integrity. Floods of anonymous requests can also reduce **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.hacktricks.wiki/en/pentesting-cloud/kubernetes-security/pentesting-kubernetes-services/index.html", + "https://docs.kics.io/develop/queries/kubernetes-queries/1de5cc51-f376-4638-a940-20f2e85ae238/", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + ], "Remediation": { "Code": { - "CLI": "--anonymous-auth=false", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-anonymous-auth-argument-is-set-to-false-1#kubernetes", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node\n2. Edit the API server static Pod manifest:\n sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[].command or args, remove \"--anonymous-auth=true\" or replace it with:\n ```\n - --anonymous-auth=false\n ```\n4. Save the file; the kubelet will automatically restart the API server with the updated flag", "Terraform": "" }, "Recommendation": { - "Text": "Ensure the --anonymous-auth argument in the API server is set to false. This will reject all anonymous requests, enforcing authenticated access to the server.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Require **authenticated access** for all API requests and avoid reliance on anonymous users. Enforce **least privilege RBAC** for explicit principals only. *If health checks must be public*, restrict to minimal paths and methods. Add **network segmentation**, mutual TLS, and **audit logging** for defense in depth.", + "Url": "https://hub.prowler.com/check/apiserver_anonymous_requests" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxage_set/apiserver_audit_log_maxage_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxage_set/apiserver_audit_log_maxage_set.metadata.json index 8221d80773..c353a73f02 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxage_set/apiserver_audit_log_maxage_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxage_set/apiserver_audit_log_maxage_set.metadata.json @@ -1,31 +1,38 @@ { "Provider": "kubernetes", "CheckID": "apiserver_audit_log_maxage_set", - "CheckTitle": "Ensure that the --audit-log-maxage argument is set to 30 or as appropriate", + "CheckTitle": "API server pod has --audit-log-maxage set to 30 (or the cluster-configured value)", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with an appropriate audit log retention period. Setting --audit-log-maxage to 30 or as per business requirements helps in maintaining logs for sufficient time to investigate past events.", - "Risk": "Without an adequate log retention period, there may be insufficient audit history to investigate and analyze past events or security incidents.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "Description": "**Kubernetes API server** audit logging retention is governed by `--audit-log-maxage`. This evaluates whether the configured value (e.g., `30` days) is set consistently across API server containers to retain audit events for a sufficient period.", + "Risk": "**Short audit retention** limits visibility into historical API actions. Credential abuse, privilege escalation, or cluster tampering may evade detection, and investigations lack evidence for timeline reconstruction-degrading data **integrity** and confidentiality through undetected unauthorized changes and exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://rke.docs.rancher.com/config-options/audit-log", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/" + ], "Remediation": { "Code": { - "CLI": "--audit-log-maxage=30", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxage-argument-is-set-to-30-or-as-appropriate#kubernetes", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to a control plane node\n2. Edit the API server static pod manifest: /etc/kubernetes/manifests/kube-apiserver.yaml\n3. Under spec.containers[0].command add:\n - --audit-log-maxage=30\n (Use your cluster-required value instead of 30 if different.)\n4. Save the file; the kubelet will restart the API server automatically", "Terraform": "" }, "Recommendation": { - "Text": "Configure the API server audit log retention period to retain logs for at least 30 days or as per your organization's requirements.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Set `--audit-log-maxage` to at least `30` days (or your policy) to support **forensics**. Align rotation with `--audit-log-maxbackup` and `--audit-log-maxsize`. Forward logs to a tamper-resistant central store, enforce **least privilege** on access, and periodically validate retention coverage.", + "Url": "https://hub.prowler.com/check/apiserver_audit_log_maxage_set" } }, "Categories": [ - "logging" + "logging", + "forensics-ready" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxbackup_set/apiserver_audit_log_maxbackup_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxbackup_set/apiserver_audit_log_maxbackup_set.metadata.json index 2257daba9b..e28422adc0 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxbackup_set/apiserver_audit_log_maxbackup_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxbackup_set/apiserver_audit_log_maxbackup_set.metadata.json @@ -1,27 +1,32 @@ { "Provider": "kubernetes", "CheckID": "apiserver_audit_log_maxbackup_set", - "CheckTitle": "Ensure that the --audit-log-maxbackup argument is set to 10 or as appropriate", + "CheckTitle": "API server pod has --audit-log-maxbackup set to 10 or the configured value", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with an appropriate number of audit log backups. Setting --audit-log-maxbackup to 10 or as per business requirements helps maintain a sufficient log backup for investigations or analysis.", - "Risk": "Without an adequate number of audit log backups, there may be insufficient log history to investigate past events or security incidents.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "Description": "**Kubernetes API server audit logging** uses `--audit-log-maxbackup` to set how many rotated audit log files are kept. This evaluates whether that value is explicitly configured as `10` or an approved organizational setting across API server containers.", + "Risk": "Insufficient **audit log retention** reduces **accountability** and hampers **forensics**. Limited backups cause older events to be overwritten, letting attackers hide activity until rotation. This undermines the **confidentiality**, **integrity**, and **availability** of evidence needed for incident response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://docs.kics.io/2.0.0/queries/kubernetes-queries/768aab52-2504-4a2f-a3e3-329d5a679848/" + ], "Remediation": { "Code": { - "CLI": "--audit-log-maxbackup=10", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxbackup-argument-is-set-to-10-or-as-appropriate#kubernetes", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control plane node\n2. Edit the static Pod manifest: /etc/kubernetes/manifests/kube-apiserver.yaml\n3. Under spec.containers[0].command, add or update this flag:\n - --audit-log-maxbackup=10\n4. Save the file; the kubelet will restart the API server automatically", "Terraform": "" }, "Recommendation": { - "Text": "Configure the API server audit log backup retention to 10 or as per your organization's requirements.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Establish explicit **audit log retention**. Set `--audit-log-maxbackup` to `10` or higher based on data sensitivity, and align with `--audit-log-maxsize` and `--audit-log-maxage`. Forward logs to centralized, immutable storage, restrict access, and monitor rotation. Apply **defense in depth** and **least privilege** to audit systems.", + "Url": "https://hub.prowler.com/check/apiserver_audit_log_maxbackup_set" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxsize_set/apiserver_audit_log_maxsize_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxsize_set/apiserver_audit_log_maxsize_set.metadata.json index f2c4135ed5..046664ee48 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxsize_set/apiserver_audit_log_maxsize_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_maxsize_set/apiserver_audit_log_maxsize_set.metadata.json @@ -1,27 +1,33 @@ { "Provider": "kubernetes", "CheckID": "apiserver_audit_log_maxsize_set", - "CheckTitle": "Ensure that the --audit-log-maxsize argument is set to 100 or as appropriate", + "CheckTitle": "API server pod has --audit-log-maxsize set to 100 MB or the configured value", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with an appropriate audit log file size limit. Setting --audit-log-maxsize to 100 MB or as per business requirements helps manage the size of log files and prevents them from growing excessively large.", - "Risk": "Without an appropriate audit log file size limit, log files can grow excessively large, potentially leading to storage issues and difficulty in log analysis.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "Description": "**Kubernetes API server** uses `--audit-log-maxsize` to cap audit log files. The check expects `100 MB` or a policy-approved value, indicating rotation occurs when a log reaches that size.", + "Risk": "Absent a proper cap, audit logs can grow unchecked, exhausting disk and degrading API server **availability**. Oversized or unbounded logs impede **forensics** and may overwrite recent events during rotation, undermining **integrity** and accountability of audit evidence.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://docs.rke2.io/security/cis_self_assessment124", + "https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/" + ], "Remediation": { "Code": { - "CLI": "--audit-log-maxsize=100", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-maxsize-argument-is-set-to-100-or-as-appropriate#kubernetes", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node\n2. Edit the API server static pod manifest: sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command add (or set) this flag:\n - --audit-log-maxsize=100\n4. Save and exit; the kubelet will restart the API server automatically\n5. Verify: ps aux | grep kube-apiserver | grep -- \"--audit-log-maxsize=100\"", "Terraform": "" }, "Recommendation": { - "Text": "Configure the API server audit log file size limit to 100 MB or as per your organization's requirements.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Set `--audit-log-maxsize` to `100 MB` or your approved baseline to ensure predictable rotation.\n\nPair with sensible retention (`--audit-log-maxage`, `--audit-log-maxbackup`), forward to a central store, and monitor capacity. This enforces **defense in depth** and preserves reliable auditability.", + "Url": "https://hub.prowler.com/check/apiserver_audit_log_maxsize_set" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_path_set/apiserver_audit_log_path_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_path_set/apiserver_audit_log_path_set.metadata.json index c2ac616606..b5b6eea18e 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_path_set/apiserver_audit_log_path_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_audit_log_path_set/apiserver_audit_log_path_set.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "apiserver_audit_log_path_set", - "CheckTitle": "Ensure that the --audit-log-path argument is set", + "CheckTitle": "API server pod has --audit-log-path set", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the Kubernetes API server is configured with an audit log path. Enabling audit logs helps in maintaining a chronological record of all activities and operations which can be critical for security analysis and troubleshooting.", - "Risk": "Without audit logs, it becomes difficult to track changes and activities within the cluster, potentially obscuring the detection of malicious activities or operational issues.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/cluster-administration/audit/", + "Description": "**Kubernetes API server** uses an **audit log path** configured via `--audit-log-path` on its containers to persist API request events", + "Risk": "Without a configured audit log path, API requests may not be recorded, weakening **accountability**. Gaps in logs hinder detection of unauthorized changes (**integrity**) and data access (**confidentiality**), and impede **forensics** and incident response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://kubernetes.io/docs/concepts/cluster-administration/audit/" + ], "Remediation": { "Code": { - "CLI": "--audit-log-path=/var/log/apiserver/audit.log", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-audit-log-path-argument-is-set#kubernetes", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. Under the kube-apiserver container command args, add this line:\n ```\n - --audit-log-path=/var/log/apiserver/audit.log\n ```\n4. Save the file; the kubelet will automatically restart the API server", "Terraform": "" }, "Recommendation": { - "Text": "Enable audit logging in the API server by specifying a valid path for --audit-log-path to ensure comprehensive activity logging within the cluster.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Enable and harden **audit logging** by setting `--audit-log-path`. *If centralizing*, use a webhook backend. Define a focused audit policy, enforce **least privilege** to logs, rotate/retain them, forward to centralized monitoring, and regularly review events for **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_audit_log_path_set" } }, "Categories": [ - "logging" + "logging", + "forensics-ready" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_include_node/apiserver_auth_mode_include_node.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_include_node/apiserver_auth_mode_include_node.metadata.json index bb9dc105bf..7a4c766b25 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_include_node/apiserver_auth_mode_include_node.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_include_node/apiserver_auth_mode_include_node.metadata.json @@ -1,31 +1,37 @@ { "Provider": "kubernetes", "CheckID": "apiserver_auth_mode_include_node", - "CheckTitle": "Ensure that the --authorization-mode argument includes Node", + "CheckTitle": "API server pod has Node in --authorization-mode", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured to include 'Node' in its --authorization-mode argument. This mode restricts kubelets to only read objects associated with their nodes, enhancing security.", - "Risk": "If the Node authorization mode is not included, kubelets may have broader access than necessary, which can pose a security risk.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/node/", + "Description": "**Kubernetes API server** authorization settings include the **Node authorizer** in `--authorization-mode`. The evaluation looks for `Node` among the configured modes.", + "Risk": "Without **Node authorization**, kubelet identities may gain overly broad permissions via other modes. A compromised node could read unrelated **Secrets**, enumerate cluster workloads, or alter node/pod status, degrading **confidentiality** and **integrity** and enabling lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://kubernetes.io/docs/reference/access-authn-authz/node/", + "https://docs.kics.io/2.0.0/queries/kubernetes-queries/4d7ee40f-fc5d-427d-8cac-dffbe22d42d1/" + ], "Remediation": { "Code": { - "CLI": "--authorization-mode=Node,RBAC", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-authorization-mode-argument-includes-node", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node\n2. Open /etc/kubernetes/manifests/kube-apiserver.yaml\n3. Under spec.containers[0].command, ensure the flag includes Node:\n - If --authorization-mode=... exists, add Node to the comma-separated list\n - If missing, add a new entry: - --authorization-mode=Node\n4. Save the file; the kubelet will restart the API server automatically", "Terraform": "" }, "Recommendation": { - "Text": "Configure the API server to use Node authorization mode along with other modes like RBAC to restrict kubelet access to the necessary resources.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Include **Node** alongside **RBAC** by adding `Node` to `--authorization-mode`. Apply **least privilege** so kubelets are limited to their node and bound pods, and use `NodeRestriction` for **defense in depth**. Periodically review kubelet permissions and audit access.", + "Url": "https://hub.prowler.com/check/apiserver_auth_mode_include_node" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_include_rbac/apiserver_auth_mode_include_rbac.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_include_rbac/apiserver_auth_mode_include_rbac.metadata.json index 15b88d34dd..b38520eccd 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_include_rbac/apiserver_auth_mode_include_rbac.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_include_rbac/apiserver_auth_mode_include_rbac.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "apiserver_auth_mode_include_rbac", - "CheckTitle": "Ensure that the --authorization-mode argument includes RBAC", + "CheckTitle": "API server pod authorization mode includes RBAC", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that Role Based Access Control (RBAC) is enabled in the Kubernetes API server's authorization mode. RBAC allows for fine-grained control over cluster operations and is recommended for secure and manageable access control.", - "Risk": "If RBAC is not included in the API server's authorization mode, the cluster may not be leveraging fine-grained access controls, leading to potential security risks.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "Description": "**Kubernetes API server** authorization configuration includes the **RBAC authorizer** in the enabled modes, i.e., `RBAC` appears in the authorizer chain.", + "Risk": "Absence of **RBAC** removes fine-grained, role-scoped control. Identities may gain broad or unintended access, enabling:\n- Secret disclosure (confidentiality)\n- Unauthorized changes to workloads/policies (integrity)\n- Destructive API calls causing outages (availability)", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + ], "Remediation": { "Code": { - "CLI": "--authorization-mode=Node,RBAC", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-authorization-mode-argument-includes-rbac", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control-plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[].command, add or update the flag to include RBAC, for example:\n - --authorization-mode=Node,RBAC\n (If --authorization-mode already exists, append ,RBAC to its value)\n4. Save the file; the kubelet will restart the API server automatically", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that the API server is configured with RBAC authorization mode for enhanced security and access control.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Adopt **RBAC** as the primary authorizer and avoid permissive modes like `AlwaysAllow` or legacy `ABAC`.\n\nEnforce **least privilege** with narrowly scoped roles and bindings, apply **separation of duties**, and monitor authorization activity for **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_auth_mode_include_rbac" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_not_always_allow/apiserver_auth_mode_not_always_allow.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_not_always_allow/apiserver_auth_mode_not_always_allow.metadata.json index 06a16e6753..be100f75e1 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_not_always_allow/apiserver_auth_mode_not_always_allow.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_auth_mode_not_always_allow/apiserver_auth_mode_not_always_allow.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "apiserver_auth_mode_not_always_allow", - "CheckTitle": "Ensure that the --authorization-mode argument is not set to AlwaysAllow", + "CheckTitle": "API server pod does not use the AlwaysAllow authorization mode", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "Severity": "critical", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is not configured to always authorize all requests. The 'AlwaysAllow' mode bypasses all authorization checks, which should not be used on production clusters.", - "Risk": "If set to AlwaysAllow, the API server would authorize all requests, potentially leading to unauthorized access and security vulnerabilities.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/authorization/#using-flags-for-your-authorization-module", + "Description": "**Kubernetes API server** authorization is evaluated via the `--authorization-mode` setting to detect any use of `AlwaysAllow`. The focus is whether policy-driven authorizers are configured instead of an allow-all mode.", + "Risk": "`AlwaysAllow` permits all API requests, eroding **confidentiality** (secrets readable), **integrity** (privilege escalation, role changes, config edits), and **availability** (object deletion, cluster disruption). Enables rapid cluster takeover and data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://kubernetes.io/docs/reference/access-authn-authz/authorization/#using-flags-for-your-authorization-module" + ], "Remediation": { "Code": { - "CLI": "--authorization-mode=RBAC", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-authorization-mode-argument-is-not-set-to-alwaysallow", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node\n2. Edit the API server static pod manifest: /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In the kube-apiserver container args, set the authorization mode (add or replace if present):\n ```yaml\n - --authorization-mode=RBAC\n ```\n4. Save the file; the kubelet will automatically restart the API server with the updated setting", "Terraform": "" }, "Recommendation": { - "Text": "Ensure the API server is using a secure authorization mode, such as RBAC, and not set to AlwaysAllow.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Use policy-based authorization and avoid `AlwaysAllow`. Prefer `RBAC` with `Node` (and Webhook if needed) to enforce **least privilege** and **separation of duties**. Define granular roles, avoid broad bindings like `cluster-admin`, and audit access for **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_auth_mode_not_always_allow" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_client_ca_file_set/apiserver_client_ca_file_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_client_ca_file_set/apiserver_client_ca_file_set.metadata.json index 8b7fb71e06..3c22829f0f 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_client_ca_file_set/apiserver_client_ca_file_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_client_ca_file_set/apiserver_client_ca_file_set.metadata.json @@ -1,31 +1,37 @@ { "Provider": "kubernetes", "CheckID": "apiserver_client_ca_file_set", - "CheckTitle": "Ensure that the --client-ca-file argument is set as appropriate", + "CheckTitle": "API server pod has the --client-ca-file argument set", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with the --client-ca-file argument, specifying the CA file for client authentication. This setting enables the API server to authenticate clients using certificates signed by the CA and is crucial for secure communication.", - "Risk": "If the client CA file is not set, the API server may not properly authenticate clients, potentially leading to unauthorized access.", - "RelatedUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/", + "Description": "**Kubernetes API server** uses a configured **client CA** (`--client-ca-file`) to validate x509 client certificates presented for API authentication", + "Risk": "**Absent a client CA**, the API server cannot validate x509 client identities, disabling mutual TLS.\n\nThis weakens authentication and can enable unauthorized reads or writes to cluster resources, impacting **confidentiality** and **integrity**, especially if other methods (e.g., anonymous or weak tokens) are misconfigured.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cjyabraham.gitlab.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://blog.codefarm.me/2019/02/01/access-kubernetes-api-with-client-certificates/", + "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths" + ], "Remediation": { "Code": { - "CLI": "--client-ca-file=", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-client-ca-file-argument-is-set-as-appropriate-scored", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control-plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In the kube-apiserver container command, add this flag (use your CA path if different):\n ```\n - --client-ca-file=/etc/kubernetes/pki/ca.crt\n ```\n4. Save the file; the kubelet will restart the API server automatically", "Terraform": "" }, "Recommendation": { - "Text": "Ensure the API server is configured with a client CA file for secure client authentication.", - "Url": "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths" + "Text": "Establish a trusted **client CA** for the API server and require **certificate-based client authentication**. Combine with **RBAC** and **least privilege**, disable anonymous access, and enforce **key rotation** and auditing to provide **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_client_ca_file_set" } }, "Categories": [ - "encryption" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_deny_service_external_ips/apiserver_deny_service_external_ips.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_deny_service_external_ips/apiserver_deny_service_external_ips.metadata.json index e55192fffb..8321dc0dc2 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_deny_service_external_ips/apiserver_deny_service_external_ips.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_deny_service_external_ips/apiserver_deny_service_external_ips.metadata.json @@ -1,32 +1,37 @@ { "Provider": "kubernetes", "CheckID": "apiserver_deny_service_external_ips", - "CheckTitle": "Ensure that the DenyServiceExternalIPs is set", + "CheckTitle": "API server pod has DenyServiceExternalIPs admission controller enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures the DenyServiceExternalIPs admission controller is enabled, which rejects all new usage of the Service field externalIPs. Enabling this controller enhances security by preventing the misuse of the externalIPs field.", - "Risk": "Not setting the DenyServiceExternalIPs admission controller could allow users to create Services with external IPs, potentially exposing services to security risks.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#denyserviceexternalips", + "Description": "**Kubernetes API server** with **DenyServiceExternalIPs** rejects net-new use of `Service.spec.externalIPs` and additions to that field on existing Services; existing values can only be removed.", + "Risk": "Without **DenyServiceExternalIPs**, users with Service create/patch rights can reroute traffic via arbitrary external IPs, enabling **man-in-the-middle**, traffic hijacking, and data exfiltration, degrading **confidentiality** and **integrity**. Attackers may also abuse `status.loadBalancer.ingress.ip` to similar effect.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#denyserviceexternalips", + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#how-do-i-turn-off-an-admission-controller" + ], "Remediation": { "Code": { - "CLI": "--disable-admission-plugins=DenyServiceExternalIPs", + "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. SSH to the control plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In the kube-apiserver command/args list, add: --disable-admission-plugins=DenyServiceExternalIPs\n4. Save the file; the kubelet will restart the API server automatically", "Terraform": "" }, "Recommendation": { - "Text": "Enable the DenyServiceExternalIPs admission controller by setting the '--disable-admission-plugins' argument in the kube-apiserver configuration.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#how-do-i-turn-off-an-admission-controller" + "Text": "Enable **DenyServiceExternalIPs** to block net-new `externalIPs` usage.\n\nApply **least privilege** RBAC on Services (including status updates), require change control for exposure, and favor controlled **Ingress/LoadBalancer** patterns. Use admission policies to tightly allow approved exceptions as **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_deny_service_external_ips" } }, "Categories": [ - "internet-exposed", - "trustboundaries" + "cluster-security", + "trust-boundaries", + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_disable_profiling/apiserver_disable_profiling.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_disable_profiling/apiserver_disable_profiling.metadata.json index 778cb04fd0..992c297c58 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_disable_profiling/apiserver_disable_profiling.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_disable_profiling/apiserver_disable_profiling.metadata.json @@ -1,31 +1,34 @@ { "Provider": "kubernetes", "CheckID": "apiserver_disable_profiling", - "CheckTitle": "Ensure that the --profiling argument is set to false", + "CheckTitle": "API server pod has profiling disabled (--profiling=false)", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that profiling is disabled in the Kubernetes API server. Profiling generates extensive data about the system's performance and operations, which, if not needed, should be disabled to reduce the attack surface.", - "Risk": "Enabled profiling can potentially expose detailed system and program data, which might be exploited for malicious purposes.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "Description": "**Kubernetes API server** runtime profiling is controlled by the `--profiling` flag. The evaluation inspects API server container arguments to confirm `--profiling=false` and that profiling endpoints (such as `/debug/pprof`) are not enabled.", + "Risk": "With profiling enabled, `/debug/pprof` exposes stack traces, heap data, and request details that can leak secrets and topology, degrading **confidentiality**. Heavy profiling queries can exhaust CPU and memory, threatening **availability**. Insight into code paths and timings can aid exploitation, affecting **integrity** of the control plane.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + ], "Remediation": { "Code": { - "CLI": "--profiling=false", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-profiling-argument-is-set-to-false-2", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command, add: `--profiling=false`\n4. Save the file; the kubelet will restart the API server automatically\n5. Verify the flag is active: `ps aux | grep kube-apiserver | grep -- '--profiling=false'`", "Terraform": "" }, "Recommendation": { - "Text": "Disable profiling in the API server unless it is necessary for troubleshooting performance bottlenecks.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/" + "Text": "Keep API server profiling disabled by default. *If diagnostics are required*, enable it briefly in a controlled, isolated environment.\n\nApply **least privilege** to debug access, restrict exposure via network controls, and audit usage. Use **defense in depth** and separation of duties for any profiling enablement.", + "Url": "https://hub.prowler.com/check/apiserver_disable_profiling" } }, "Categories": [ - "trustboundaries" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_encryption_provider_config_set/apiserver_encryption_provider_config_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_encryption_provider_config_set/apiserver_encryption_provider_config_set.metadata.json index add8c04776..87b978b6fb 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_encryption_provider_config_set/apiserver_encryption_provider_config_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_encryption_provider_config_set/apiserver_encryption_provider_config_set.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_encryption_provider_config_set", - "CheckTitle": "Ensure that the --encryption-provider-config argument is set as appropriate", + "CheckTitle": "API server pod has the --encryption-provider-config argument set", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with the --encryption-provider-config argument to encrypt sensitive data at rest in the etcd key-value store. Encrypting data at rest prevents potential unauthorized disclosures and ensures that the sensitive data is secure.", - "Risk": "Without proper configuration of the encryption provider, sensitive data stored in etcd might not be encrypted, posing a risk of data breaches.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/", + "Description": "**Kubernetes API server** pods include `--encryption-provider-config`, supplying an EncryptionConfiguration to apply **encryption at rest** to selected API resources stored in etcd.", + "Risk": "Absent an encryption provider, **Secrets and credentials** are stored in plaintext in etcd and backups. Access to etcd, control plane disks, or snapshots can expose keys and tokens, enabling unauthorized API calls and lateral movement, compromising **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/#determining-whether-encryption-at-rest-is-already-enabled" + ], "Remediation": { "Code": { - "CLI": "--encryption-provider-config=/path/to/EncryptionConfig/File", + "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. SSH to each control-plane node\n2. Create the encryption config file at /etc/kubernetes/enc/enc.yaml:\n ```yaml\n apiVersion: apiserver.config.k8s.io/v1\n kind: EncryptionConfiguration\n resources:\n - resources: [\"secrets\"]\n providers:\n - aescbc:\n keys:\n - name: key1\n secret: \n - identity: {}\n ```\n3. Edit /etc/kubernetes/manifests/kube-apiserver.yaml and:\n - Add the flag under the kube-apiserver container command:\n ```\n - --encryption-provider-config=/etc/kubernetes/enc/enc.yaml\n ```\n - Mount the config path:\n ```yaml\n volumeMounts:\n - name: enc\n mountPath: /etc/kubernetes/enc\n readOnly: true\n ...\n volumes:\n - name: enc\n hostPath:\n path: /etc/kubernetes/enc\n type: DirectoryOrCreate\n ```\n Save the file; the kubelet will restart the API server.\n4. Repeat on all control-plane nodes.", "Terraform": "" }, "Recommendation": { - "Text": "Configure and enable encryption for data at rest in etcd using a suitable EncryptionConfig file.", - "Url": "https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/#determining-whether-encryption-at-rest-is-already-enabled" + "Text": "Enable **encryption at rest** with an `EncryptionConfiguration` and run with `--encryption-provider-config` using a non-`identity` provider (prefer `kms v2`). Apply **least privilege** to key/KMS access, rotate keys, restrict config file access, keep settings consistent across API servers, and re-encrypt existing objects.", + "Url": "https://hub.prowler.com/check/apiserver_encryption_provider_config_set" } }, "Categories": [ - "encryption" + "encryption", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_etcd_cafile_set/apiserver_etcd_cafile_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_etcd_cafile_set/apiserver_etcd_cafile_set.metadata.json index 2c5af229b3..0ca983bec7 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_etcd_cafile_set/apiserver_etcd_cafile_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_etcd_cafile_set/apiserver_etcd_cafile_set.metadata.json @@ -1,32 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_etcd_cafile_set", - "CheckTitle": "Ensure that the --etcd-cafile argument is set as appropriate", + "CheckTitle": "API server pod has the --etcd-cafile argument set", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "Severity": "critical", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with the --etcd-cafile argument, specifying the Certificate Authority file for etcd client connections. This setting is important for secure communication with etcd and ensures that the API server connects to etcd with an SSL Certificate Authority file.", - "Risk": "Without proper TLS configuration, communication between the API server and etcd can be unencrypted, leading to potential security vulnerabilities.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/", + "Description": "**Kubernetes API server** uses an **etcd CA file** via `--etcd-cafile` to verify etcd's TLS certificate.\n\nThis evaluates whether API server containers specify that CA file, anchoring TLS trust for etcd connections.", + "Risk": "Without a validated **etcd CA**, the API server may accept rogue or intercepted endpoints, undermining:\n- **Confidentiality**: exposure of cluster data in transit\n- **Integrity**: tampering with Kubernetes state in etcd\n- **Availability**: control plane disruption via malicious etcd responses", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#limiting-access-of-etcd-clusters" + ], "Remediation": { "Code": { - "CLI": "--etcd-cafile=", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-etcd-cafile-argument-is-set-as-appropriate-1", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node running kube-apiserver\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command, add the flag line:\n ```\n - --etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt\n ```\n4. Save the file; the kubelet will restart the API server automatically", "Terraform": "" }, "Recommendation": { - "Text": "Ensure etcd connections from the API server are secured using the appropriate CA file.", - "Url": "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#limiting-access-of-etcd-clusters" + "Text": "Anchor etcd connections in **mutual TLS**: provide a trusted CA (`--etcd-cafile`) and unique client credentials, rotate keys, and prefer strong ciphers.\n\nApply **least privilege** and **network segmentation** so only API servers can reach etcd; disable plaintext or unauthenticated access.", + "Url": "https://hub.prowler.com/check/apiserver_etcd_cafile_set" } }, "Categories": [ "encryption", - "encryption" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_etcd_tls_config/apiserver_etcd_tls_config.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_etcd_tls_config/apiserver_etcd_tls_config.metadata.json index 5f22c3e7a3..f4e9031387 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_etcd_tls_config/apiserver_etcd_tls_config.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_etcd_tls_config/apiserver_etcd_tls_config.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_etcd_tls_config", - "CheckTitle": "Ensure that the --etcd-certfile and --etcd-keyfile arguments are set as appropriate", + "CheckTitle": "API server pod has --etcd-certfile and --etcd-keyfile configured for etcd TLS", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "Severity": "critical", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with TLS encryption for etcd client connections, using --etcd-certfile and --etcd-keyfile arguments. Setting up TLS for etcd is crucial for securing the sensitive data stored in etcd as it's the primary datastore for Kubernetes.", - "Risk": "Without TLS encryption, data stored in etcd is susceptible to eavesdropping and man-in-the-middle attacks, potentially leading to data breaches.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/", + "Description": "**Kubernetes API server** uses **TLS** for its etcd client connection, signaled by `--etcd-certfile` and `--etcd-keyfile` in the API server pod arguments.\n\nThis evaluates whether client-certificate authentication is configured between the API server and etcd.", + "Risk": "Without **TLS and client certs**, API server-etcd traffic can be **intercepted or altered**, compromising **confidentiality** (Secrets, tokens), **integrity** (state tampering), and **availability** (control-plane instability). Attackers could perform MITM, exfiltrate data, or inject state to seize cluster control.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#limiting-access-of-etcd-clusters" + ], "Remediation": { "Code": { - "CLI": "--etcd-certfile= --etcd-keyfile=", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-etcd-certfile-and-etcd-keyfile-arguments-are-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command add:\n - --etcd-certfile=\n - --etcd-keyfile=\n4. Save the file; the kubelet will restart the API server with the new flags", "Terraform": "" }, "Recommendation": { - "Text": "Enable TLS encryption for etcd client connections to secure sensitive data.", - "Url": "https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/#limiting-access-of-etcd-clusters" + "Text": "Enforce **mutual TLS** between API server and etcd with trusted CAs and unique client certificates. Restrict etcd network access to control-plane nodes, rotate keys, and monitor certificate expiry. Apply **least privilege** and **defense in depth** using private networking and firewall policies.", + "Url": "https://hub.prowler.com/check/apiserver_etcd_tls_config" } }, "Categories": [ - "encryption" + "encryption", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_event_rate_limit/apiserver_event_rate_limit.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_event_rate_limit/apiserver_event_rate_limit.metadata.json index 27129c576e..9f43331644 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_event_rate_limit/apiserver_event_rate_limit.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_event_rate_limit/apiserver_event_rate_limit.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_event_rate_limit", - "CheckTitle": "Ensure that the admission control plugin EventRateLimit is set", + "CheckTitle": "API server pod has the EventRateLimit admission control plugin enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies if the Kubernetes API server is configured with the EventRateLimit admission control plugin. This plugin limits the rate of events accepted by the API Server, preventing potential DoS attacks by misbehaving workloads.", - "Risk": "Without EventRateLimit, the API server could be overwhelmed by a high number of events, leading to DoS and performance issues.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/", + "Description": "**Kubernetes API server** includes `EventRateLimit` among its enabled admission plugins, applying rate controls to Kubernetes `Event` objects during admission", + "Risk": "Without **event rate limiting**, bursts of Event writes from noisy or hostile workloads can overwhelm the API server, degrading **availability**. This leads to API timeouts, slow or stalled controller reconciliations, and amplifies **DoS** against control-plane endpoints.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#eventratelimit" + ], "Remediation": { "Code": { - "CLI": "--enable-admission-plugins=...,EventRateLimit,... --admission-control-config-file=/path/to/configuration/file", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-eventratelimit-is-set", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control-plane node and edit the API server static pod manifest:\n - File: /etc/kubernetes/manifests/kube-apiserver.yaml\n2. In the kube-apiserver container args/command list, ensure EventRateLimit is enabled:\n - If the flag exists, append EventRateLimit to the list:\n --enable-admission-plugins=... ,EventRateLimit\n - If missing, add it:\n --enable-admission-plugins=EventRateLimit\n3. Save the file. The kubelet will restart the API server automatically and the check will pass.", "Terraform": "" }, "Recommendation": { - "Text": "Configure EventRateLimit as an admission control plugin for the API server to manage the rate of incoming events effectively.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#eventratelimit" + "Text": "Use the `EventRateLimit` admission plugin with conservative, workload-aware thresholds (global, per-namespace, per-user) to cap Event throughput.\n\nApply **defense in depth**: monitor Event volume, alert on spikes, tame noisy emitters, and uphold **least privilege** to preserve API capacity.", + "Url": "https://hub.prowler.com/check/apiserver_event_rate_limit" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "resilience" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_kubelet_cert_auth/apiserver_kubelet_cert_auth.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_kubelet_cert_auth/apiserver_kubelet_cert_auth.metadata.json index 65e4fef845..43e660830c 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_kubelet_cert_auth/apiserver_kubelet_cert_auth.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_kubelet_cert_auth/apiserver_kubelet_cert_auth.metadata.json @@ -1,32 +1,37 @@ { "Provider": "kubernetes", "CheckID": "apiserver_kubelet_cert_auth", - "CheckTitle": "Ensure that the --kubelet-certificate-authority argument is set as appropriate", + "CheckTitle": "API server pod has --kubelet-certificate-authority configured", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "Severity": "critical", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is set up with a specified certificate authority for kubelet connections, using the --kubelet-certificate-authority argument. This setup is crucial for verifying the kubelet's certificate to prevent man-in-the-middle attacks during connections from the apiserver to the kubelet.", - "Risk": "Without the --kubelet-certificate-authority argument, connections to kubelets are not verified, increasing the risk of man-in-the-middle attacks, especially over untrusted networks.", - "RelatedUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/", + "Description": "**Kubernetes API server** is configured with a **kubelet certificate authority** via `--kubelet-certificate-authority` so it can validate kubelet serving certificates during APIkubelet TLS connections.", + "Risk": "Without a trusted kubelet CA, the API server can't verify kubelet identities, weakening TLS and enabling **man-in-the-middle** on control planenode traffic. Attackers could read logs/exec streams (**Confidentiality**), tamper with responses or commands (**Integrity**), and disrupt node management (**Availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://kubernetes.io/docs/setup/best-practices/certificates/#configure-certificates-manually", + "https://docs.kics.io/latest/queries/kubernetes-queries/ec18a0d3-0069-4a58-a7fb-fbfe0b4bbbe0/" + ], "Remediation": { "Code": { - "CLI": "--kubelet-certificate-authority=/path/to/ca-file", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-kubelet-certificate-authority-argument-is-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "```yaml\n# kube-apiserver Pod with kubelet CA configured\napiVersion: v1\nkind: Pod\nmetadata:\n name: \n namespace: kube-system\nspec:\n containers:\n - name: kube-apiserver\n image: registry.k8s.io/kube-apiserver:v1.27.0\n command:\n - kube-apiserver\n - --kubelet-certificate-authority=/etc/kubernetes/pki/ca.crt # Critical: verifies kubelet certs using this CA to secure apiserver<->kubelet\n```", + "Other": "1. SSH to the control-plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In containers[].command, add: --kubelet-certificate-authority=/etc/kubernetes/pki/ca.crt\n4. Save the file; the kubelet will restart the API server automatically\n5. Verify the flag is present in the manifest and the apiserver pod is Running in kube-system", "Terraform": "" }, "Recommendation": { - "Text": "Enable TLS verification between the apiserver and kubelets by specifying the certificate authority in the kube-apiserver configuration.", - "Url": "https://kubernetes.io/docs/setup/best-practices/certificates/#configure-certificates-manually" + "Text": "Enforce **mutual TLS** for API server-kubelet communication. Provide a trusted CA using `--kubelet-certificate-authority`, issue certs from controlled PKI, rotate keys, and limit client credentials per *least privilege*. Prefer private networking and layered controls for **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_kubelet_cert_auth" } }, "Categories": [ "cluster-security", - "internet-exposed" + "encryption" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_kubelet_tls_auth/apiserver_kubelet_tls_auth.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_kubelet_tls_auth/apiserver_kubelet_tls_auth.metadata.json index 054c2743f6..4b2c61bd12 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_kubelet_tls_auth/apiserver_kubelet_tls_auth.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_kubelet_tls_auth/apiserver_kubelet_tls_auth.metadata.json @@ -1,32 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_kubelet_tls_auth", - "CheckTitle": "Ensure that the --kubelet-client-certificate and --kubelet-client-key arguments are set as appropriate", + "CheckTitle": "API server pod has --kubelet-client-certificate and --kubelet-client-key arguments configured", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "Severity": "critical", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is set up with certificate-based authentication to the kubelet. This setup requires the --kubelet-client-certificate and --kubelet-client-key arguments in the kube-apiserver configuration to be set, ensuring secure communication between the API server and kubelets.", - "Risk": "Without certificate-based authentication to kubelets, requests from the apiserver are treated as anonymous, which could lead to unauthorized access and manipulation of node resources.", - "RelatedUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/", + "Description": "**Kubernetes API server** is configured to use **TLS client certificates** when communicating with kubelets via `--kubelet-client-certificate` and `--kubelet-client-key`.", + "Risk": "Without **mTLS to kubelets**, apiserver requests may be **anonymous or weakly authenticated**. Adversaries can abuse kubelet endpoints to:\n- Read logs and files (C)\n- Exec into pods (I)\n- Evict or disrupt pods (A)\nEnables lateral movement and workload tampering.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/setup/best-practices/certificates/#configure-certificates-manually" + ], "Remediation": { "Code": { - "CLI": "--kubelet-client-certificate=/path/to/client-certificate-file --kubelet-client-key=/path/to/client-key-file", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-kubelet-client-certificate-and-kubelet-client-key-arguments-are-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node.\n2. Edit the API server static pod manifest: /etc/kubernetes/manifests/kube-apiserver.yaml\n3. Under spec.containers[0].command add both flags (use existing certs in /etc/kubernetes/pki):\n \n --kubelet-client-certificate=/etc/kubernetes/pki/apiserver-kubelet-client.crt\n --kubelet-client-key=/etc/kubernetes/pki/apiserver-kubelet-client.key\n\n4. Save the file; the kubelet will automatically restart the API server with the new flags.", "Terraform": "" }, "Recommendation": { - "Text": "Enable TLS authentication between the apiserver and kubelets by specifying the client certificate and key in the kube-apiserver configuration.", - "Url": "https://kubernetes.io/docs/setup/best-practices/certificates/#configure-certificates-manually" + "Text": "Enforce **mutual TLS** between apiserver and kubelets using a dedicated client certificate/key (`--kubelet-client-certificate`, `--kubelet-client-key`) signed by a trusted CA. Apply **least privilege** to kubelet authorization and disable **anonymous access** to strengthen defense-in-depth.", + "Url": "https://hub.prowler.com/check/apiserver_kubelet_tls_auth" } }, "Categories": [ "cluster-security", - "internet-exposed" + "encryption" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_namespace_lifecycle_plugin/apiserver_namespace_lifecycle_plugin.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_namespace_lifecycle_plugin/apiserver_namespace_lifecycle_plugin.metadata.json index 18b5fc10a6..fbd661a1c7 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_namespace_lifecycle_plugin/apiserver_namespace_lifecycle_plugin.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_namespace_lifecycle_plugin/apiserver_namespace_lifecycle_plugin.metadata.json @@ -1,27 +1,30 @@ { "Provider": "kubernetes", "CheckID": "apiserver_namespace_lifecycle_plugin", - "CheckTitle": "Ensure that the admission control plugin NamespaceLifecycle is set", + "CheckTitle": "API server pod has NamespaceLifecycle admission control plugin enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the NamespaceLifecycle admission control plugin is enabled in the Kubernetes API server. This plugin prevents the creation of objects in non-existent or terminating namespaces, enforcing the integrity of the namespace lifecycle and availability of new objects.", - "Risk": "Without NamespaceLifecycle, objects may be created in namespaces that are being terminated, potentially leading to inconsistencies and resource conflicts.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/", + "Description": "**Kubernetes API server** has the `NamespaceLifecycle` admission controller active and not disabled, enforcing namespace lifecycle rules by rejecting objects targeting **non-existent** or **terminating** namespaces and protecting system namespaces from deletion.", + "Risk": "Without `NamespaceLifecycle`, resources can be created in namespaces being removed or that never existed, causing inconsistent state and controller errors.\n\nThis degrades **integrity** and **availability**, leaving orphaned objects, delaying cleanup, and potentially preserving access via leftover service accounts or policies.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#namespacelifecycle" + ], "Remediation": { "Code": { - "CLI": "--enable-admission-plugins=...,NamespaceLifecycle,...", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-namespacelifecycle-is-set", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node.\n2. Edit the API server manifest: /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command, ensure NamespaceLifecycle is enabled and not disabled:\n ```yaml\n # Critical: enable NamespaceLifecycle plugin\n - --enable-admission-plugins=NamespaceLifecycle\n # If this flag exists with a list, append ,NamespaceLifecycle to it\n # Ensure NamespaceLifecycle is NOT present in:\n # - --disable-admission-plugins=...\n ```\n4. Save the file; the kubelet will restart the API server automatically.\n5. Verify the setting is present in the running pod spec:\n ```\n kubectl -n kube-system get pod -l component=kube-apiserver -o jsonpath='{.items[0].spec.containers[0].command}' | grep NamespaceLifecycle\n ```", "Terraform": "" }, "Recommendation": { - "Text": "Enable the NamespaceLifecycle admission control plugin in the API server to enforce proper namespace management.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#namespacelifecycle" + "Text": "Ensure `NamespaceLifecycle` remains enabled to enforce namespace governance. Apply **least privilege** for namespace creation/deletion, and use **separation of duties** for approvals. Monitor deletions and remediate stuck finalizers so cleanup completes. Combine with RBAC and audit logs for **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_namespace_lifecycle_plugin" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_no_always_admit_plugin/apiserver_no_always_admit_plugin.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_no_always_admit_plugin/apiserver_no_always_admit_plugin.metadata.json index 3f1f36e0cd..77ad482fbd 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_no_always_admit_plugin/apiserver_no_always_admit_plugin.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_no_always_admit_plugin/apiserver_no_always_admit_plugin.metadata.json @@ -1,31 +1,34 @@ { "Provider": "kubernetes", "CheckID": "apiserver_no_always_admit_plugin", - "CheckTitle": "Ensure that the admission control plugin AlwaysAdmit is not set", + "CheckTitle": "API server pod does not have the AlwaysAdmit admission control plugin enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "Severity": "critical", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the Kubernetes API server is not configured with the AlwaysAdmit admission control plugin. The AlwaysAdmit plugin allows all requests without any filtering, which is a security risk and is deprecated.", - "Risk": "Enabling AlwaysAdmit permits all requests by default, bypassing other admission control checks, which can lead to unauthorized access.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/", + "Description": "**Kubernetes API server** configuration is inspected for the `AlwaysAdmit` admission plugin in `--enable-admission-plugins`.\n\nIf `AlwaysAdmit` is configured, the server accepts all admission requests without running other admission controllers.", + "Risk": "With **AlwaysAdmit**, admission policies don't run after authN/Z, weakening CIA:\n- Bypass of controls enables privileged or unsafe workloads (confidentiality, integrity)\n- Quotas/limits can be ignored, causing resource exhaustion (availability)\n- Misconfigurations persist, enabling escalation and lateral movement", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwaysadmit" + ], "Remediation": { "Code": { - "CLI": "--disable-admission-plugins=...,AlwaysAdmit,...", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-alwaysadmit-is-not-set", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to a control plane node\n2. Edit the static pod manifest: sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[].command, locate the flag --enable-admission-plugins=...\n4. Remove \"AlwaysAdmit\" from the comma-separated list (if it is the only value, remove the entire flag)\n5. Save the file; the kubelet will restart the API server automatically\n6. Verify it's gone: kubectl -n kube-system describe pod | grep -- --enable-admission-plugins (ensure AlwaysAdmit is not present)", "Terraform": "" }, "Recommendation": { - "Text": "Ensure the API server does not use the AlwaysAdmit admission control plugin to maintain proper security checks for all requests.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#alwaysadmit" + "Text": "Exclude `AlwaysAdmit` from API server settings. Use a **deny-by-default** admission posture and enable only necessary controllers to enforce policy and limits (e.g., PodSecurity, ResourceQuota, LimitRanger).\n\nApply **least privilege**, regularly review admission configuration, and audit API activity to detect drift.", + "Url": "https://hub.prowler.com/check/apiserver_no_always_admit_plugin" } }, "Categories": [ - "trustboundaries" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_no_token_auth_file/apiserver_no_token_auth_file.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_no_token_auth_file/apiserver_no_token_auth_file.metadata.json index f062935563..b155ade690 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_no_token_auth_file/apiserver_no_token_auth_file.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_no_token_auth_file/apiserver_no_token_auth_file.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_no_token_auth_file", - "CheckTitle": "Ensure that the --token-auth-file parameter is not set", + "CheckTitle": "API server pod does not have --token-auth-file enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is not using static token-based authentication, which is less secure. Static tokens are stored in clear-text and lack features like revocation or rotation without restarting the API server.", - "Risk": "Using static token-based authentication exposes the cluster to security risks due to the static nature of the tokens, their clear-text storage, and the inability to revoke or rotate them easily.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/authentication/", + "Description": "**Kubernetes API server** configuration is reviewed for use of **static token file authentication** by inspecting API server containers for the `--token-auth-file` argument", + "Risk": "Using **static bearer tokens** undermines CIA:\n- Confidentiality: leaked tokens grant API access\n- Integrity: long-lived tokens enable unauthorized changes\n- Availability: access can't be revoked quickly\nTokens are clear-text and lack **revocation/rotation**, enabling persistent access if exposed.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/authentication/#static-token-file" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-token-auth-file-parameter-is-not-set", - "Other": "", + "NativeIaC": "", + "Other": "1. SSH to each control plane node\n2. Open /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command, remove the line containing: --token-auth-file= \n4. Save the file; the kubelet will automatically restart the API server\n5. Repeat on all control plane nodes\n6. Verify the flag is absent: kubectl -n kube-system get pods -l component=kube-apiserver -o yaml | grep -- --token-auth-file || echo \"not present\"", "Terraform": "" }, "Recommendation": { - "Text": "Replace token-based authentication with more secure mechanisms like client certificate authentication. Ensure the --token-auth-file argument is not used in the API server configuration.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/authentication/#static-token-file" + "Text": "Avoid **static token files**. Prefer **client certificates**, **OIDC/webhook authenticators**, or **service accounts** with short-lived tokens. Apply **least privilege** with RBAC, enforce **rotation** and short expirations, and disable `--token-auth-file` to support **defense in depth** and rapid credential revocation.", + "Url": "https://hub.prowler.com/check/apiserver_no_token_auth_file" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_node_restriction_plugin/apiserver_node_restriction_plugin.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_node_restriction_plugin/apiserver_node_restriction_plugin.metadata.json index d6903c2ea8..41142631e4 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_node_restriction_plugin/apiserver_node_restriction_plugin.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_node_restriction_plugin/apiserver_node_restriction_plugin.metadata.json @@ -1,31 +1,38 @@ { "Provider": "kubernetes", "CheckID": "apiserver_node_restriction_plugin", - "CheckTitle": "Ensure that the admission control plugin NodeRestriction is set", + "CheckTitle": "API server pod has NodeRestriction admission control plugin enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the NodeRestriction admission control plugin is enabled in the Kubernetes API server. NodeRestriction limits the Node and Pod objects that a kubelet can modify, enhancing security by ensuring kubelets are restricted to manage their own node and pods.", - "Risk": "Without NodeRestriction, kubelets may have broader access to Node and Pod objects, potentially leading to unauthorized modifications and security risks.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers", + "Description": "**Kubernetes API server** has the **NodeRestriction** admission controller enabled via `--enable-admission-plugins`.\n\nThis setting confines kubelets to modify only their own `Node` object and bound `Pod` objects.", + "Risk": "Without **NodeRestriction**, a compromised or misconfigured kubelet could alter other nodes or pods, change critical labels/taints, or delete node objects.\n\nThis enables lateral movement and workload hijacking, impacting **integrity** and **availability** of the cluster.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#noderestriction", + "https://blog.codefarm.me/2021/12/15/kubernetes-admission-controllers/", + "https://cjyabraham.gitlab.io/docs/reference/access-authn-authz/admission-controllers/" + ], "Remediation": { "Code": { - "CLI": "--enable-admission-plugins=...,NodeRestriction,...", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-noderestriction-is-set", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node running the API server\n2. Edit the static pod manifest: /etc/kubernetes/manifests/kube-apiserver.yaml\n3. Under spec > containers[0] > command, ensure this flag is present and includes NodeRestriction (add it if missing):\n - --enable-admission-plugins=NodeRestriction\n If the flag already exists with other plugins, append ,NodeRestriction to the comma-separated list\n4. Save the file; the kubelet will automatically restart the API server", "Terraform": "" }, "Recommendation": { - "Text": "Enable the NodeRestriction admission control plugin in the API server for enhanced node and pod security.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#noderestriction" + "Text": "Enable the **NodeRestriction** admission controller to enforce **least privilege** for kubelets.\n\nPair it with **Node** and **RBAC** authorization, strong kubelet identity, and audit monitoring for defense-in-depth. Regularly rotate credentials and limit kubelet access to only its node.", + "Url": "https://hub.prowler.com/check/apiserver_node_restriction_plugin" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_request_timeout_set/apiserver_request_timeout_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_request_timeout_set/apiserver_request_timeout_set.metadata.json index b5dab0928c..86152e0a74 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_request_timeout_set/apiserver_request_timeout_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_request_timeout_set/apiserver_request_timeout_set.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_request_timeout_set", - "CheckTitle": "Ensure that the --request-timeout argument is set as appropriate", + "CheckTitle": "API server pod has --request-timeout configured", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the Kubernetes API server is configured with an appropriate global request timeout. Setting a suitable --request-timeout value ensures the API server can handle requests efficiently without exhausting resources, especially in cases of slower connections or high-volume data requests.", - "Risk": "An inadequately set request timeout may lead to inefficient handling of API requests, either by timing out too quickly on slow connections or by allowing requests to consume excessive resources, leading to potential Denial-of-Service attacks.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "Description": "**Kubernetes API server** has a global request timeout configured via `--request-timeout`.\n\nThe presence of that flag on API server containers is assessed.", + "Risk": "Without a defined, appropriate timeout, requests can linger and tie up handlers, reducing **availability**. Attackers can perform slowloris-style holds or heavy reads to cause **resource exhaustion** and backlog growth. Overly short timeouts can cut valid calls, disrupting controllers and clients.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/#options" + ], "Remediation": { "Code": { - "CLI": "--request-timeout=300s", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-request-timeout-argument-is-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control-plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In the kube-apiserver container's command list, add:\n ```yaml\n - --request-timeout=300s\n ```\n4. Save the file; the kubelet will automatically restart the API server\n5. Repeat on all control-plane nodes", "Terraform": "" }, "Recommendation": { - "Text": "Set the API server request timeout to a value that balances resource usage efficiency and the needs of your environment, considering connection speeds and data volumes.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/#options" + "Text": "Set `--request-timeout` to a bounded value aligned to typical non-watch calls; *when needed*, tune `--min-request-timeout` for watches. Combine with **priority and fairness**, rate limiting, and load testing to prevent starvation. Monitor latency and errors to adjust. Apply **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_request_timeout_set" } }, "Categories": [ - "cluster-security" + "cluster-security", + "resilience" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_security_context_deny_plugin/apiserver_security_context_deny_plugin.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_security_context_deny_plugin/apiserver_security_context_deny_plugin.metadata.json index b0292f1564..099595c5a3 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_security_context_deny_plugin/apiserver_security_context_deny_plugin.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_security_context_deny_plugin/apiserver_security_context_deny_plugin.metadata.json @@ -1,31 +1,34 @@ { "Provider": "kubernetes", "CheckID": "apiserver_security_context_deny_plugin", - "CheckTitle": "Ensure that the admission control plugin SecurityContextDeny is set if PodSecurityPolicy is not used", + "CheckTitle": "API server pod uses PodSecurityPolicy or has the SecurityContextDeny admission plugin enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the SecurityContextDeny admission control plugin is enabled in the Kubernetes API server if PodSecurityPolicy is not used. The SecurityContextDeny plugin denies pods that make use of certain SecurityContext fields which could allow privilege escalation.", - "Risk": "Without SecurityContextDeny, pods may be able to escalate privileges if PodSecurityPolicy is not used, potentially leading to security vulnerabilities.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/", + "Description": "**Kubernetes API server** admission configuration is reviewed for `PodSecurityPolicy` or `SecurityContextDeny`, indicating whether pods using high-risk `securityContext` fields (privileged, host access, extra capabilities) would be blocked during admission.", + "Risk": "Without these controls, pods can request privileged mode, host namespaces, or excessive capabilities, enabling container escape, node compromise, and data exfiltration. This undermines **integrity** and **confidentiality**, and can impact **availability** via lateral movement or disruptive actions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#securitycontextdeny" + ], "Remediation": { "Code": { - "CLI": "--enable-admission-plugins=...,SecurityContextDeny,...", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-securitycontextdeny-is-set-if-podsecuritypolicy-is-not-used", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to a control-plane node\n2. Edit /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command:\n - If --enable-admission-plugins=... exists, append \",SecurityContextDeny\" to its list\n - If absent, add a new arg line: - --enable-admission-plugins=SecurityContextDeny\n4. Save; the kubelet will restart the API server", "Terraform": "" }, "Recommendation": { - "Text": "Use SecurityContextDeny as an admission control plugin in the API server to enhance security, especially in the absence of PodSecurityPolicy.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#securitycontextdeny" + "Text": "Apply defense-in-depth at admission:\n- Prefer **Pod Security Admission** with `restricted` policies\n- *For legacy clusters*, enable `SecurityContextDeny` or an equivalent policy engine\n- Enforce **least privilege**: set `allowPrivilegeEscalation=false`, drop unnecessary capabilities, and avoid host namespaces.", + "Url": "https://hub.prowler.com/check/apiserver_security_context_deny_plugin" } }, "Categories": [ - "trustboundaries" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_key_file_set/apiserver_service_account_key_file_set.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_key_file_set/apiserver_service_account_key_file_set.metadata.json index d33696a679..c740585b7f 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_key_file_set/apiserver_service_account_key_file_set.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_key_file_set/apiserver_service_account_key_file_set.metadata.json @@ -1,32 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_service_account_key_file_set", - "CheckTitle": "Ensure that the --service-account-key-file argument is set as appropriate", + "CheckTitle": "API server pod has --service-account-key-file configured", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with a --service-account-key-file argument, specifying the public key file for service account verification. A separate key pair for service accounts enhances security by enabling key rotation and ensuring service account tokens are verified with a specific public key.", - "Risk": "Without a specified service account public key file, the API server may use the private key from its TLS serving certificate, hindering the ability to rotate keys and increasing security risks.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/", + "Description": "**Kubernetes API server** uses `--service-account-key-file` to supply the public key(s) for validating **service account tokens**.\n\nDetection looks for API server containers that lack this flag.", + "Risk": "Without a dedicated key file, token validation can fall back to the API server's TLS key, eroding **separation of duties**. Compromise or rotation of that key can enable **token forgery** (confidentiality/integrity) or invalidate tokens, disrupting workloads (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#serviceaccount-token-volume-projection" + ], "Remediation": { "Code": { - "CLI": "--service-account-key-file=", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-service-account-key-file-argument-is-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node.\n2. Edit the API server static pod manifest:\n sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command add this line:\n ```\n - --service-account-key-file=/etc/kubernetes/pki/sa.pub\n ```\n4. Save the file; the kubelet will restart the API server automatically.", "Terraform": "" }, "Recommendation": { - "Text": "Specify a separate public key file for verifying service account tokens in pod {pod.name}.", - "Url": "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#serviceaccount-token-volume-projection" + "Text": "Use a dedicated key pair for **service accounts**:\n- Configure `--service-account-key-file` with public keys for validation\n- Keep signing and serving keys separate (*least privilege*)\n- Enforce scheduled key rotation and maintain multiple active keys for **defense in depth**", + "Url": "https://hub.prowler.com/check/apiserver_service_account_key_file_set" } }, "Categories": [ - "trustboundaries", - "encryption" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_lookup_true/apiserver_service_account_lookup_true.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_lookup_true/apiserver_service_account_lookup_true.metadata.json index dcc8588b4e..483c4fd11c 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_lookup_true/apiserver_service_account_lookup_true.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_lookup_true/apiserver_service_account_lookup_true.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "apiserver_service_account_lookup_true", - "CheckTitle": "Ensure that the --service-account-lookup argument is set to true", + "CheckTitle": "API server pod has --service-account-lookup set to true", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with --service-account-lookup set to true. This setting validates the service account associated with each request, ensuring that the service account token is not only valid but also currently exists.", - "Risk": "If --service-account-lookup is disabled, deleted service accounts might still be used, posing a security risk.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/authentication/", + "Description": "Kubernetes API server has **service account lookup** enabled via `--service-account-lookup=true`, validating presented service account tokens against currently existing ServiceAccounts during authentication.", + "Risk": "Without **service account lookup**, tokens tied to deleted or renamed ServiceAccounts can still authenticate, enabling persistence with stale credentials, unauthorized API access, and lateral movement, degrading **confidentiality** and **integrity** of cluster resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "https://kubernetes.io/docs/reference/access-authn-authz/authentication/#service-account-tokens" + ], "Remediation": { "Code": { - "CLI": "--service-account-lookup=true", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-service-account-lookup-argument-is-set-to-true", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control plane node with root privileges\n2. Edit the static Pod manifest: sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In the kube-apiserver container command list, add the flag:\n ```\n --service-account-lookup=true\n ```\n4. Save the file; the kubelet will automatically restart the API server\n5. Verify the flag is active: ps aux | grep kube-apiserver | grep -- --service-account-lookup=true", "Terraform": "" }, "Recommendation": { - "Text": "Enable service account lookup in the API server to ensure that only existing service accounts are used for authentication.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/authentication/#service-account-tokens" + "Text": "Enable `--service-account-lookup=true` so token validity depends on the ServiceAccount's current state. Apply **least privilege** to ServiceAccounts, favor short-lived tokens, and promptly remove unused accounts and secrets. Combine with strict **RBAC** and auditing for **defense in depth**.", + "Url": "https://hub.prowler.com/check/apiserver_service_account_lookup_true" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_plugin/apiserver_service_account_plugin.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_plugin/apiserver_service_account_plugin.metadata.json index 5bd77a0ed8..f3c4602d6a 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_plugin/apiserver_service_account_plugin.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_service_account_plugin/apiserver_service_account_plugin.metadata.json @@ -1,32 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_service_account_plugin", - "CheckTitle": "Ensure that the admission control plugin ServiceAccount is set", + "CheckTitle": "API server pod has ServiceAccount admission control plugin enabled", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesAPIServer", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the ServiceAccount admission control plugin is enabled in the Kubernetes API server. This plugin automates the creation and assignment of service accounts to pods, enhancing security by managing service account tokens.", - "Risk": "If the ServiceAccount admission plugin is disabled, pods might be assigned the default service account without proper token management, leading to potential security risks.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/", + "Description": "**Kubernetes API server** includes the **ServiceAccount admission controller** (`ServiceAccount`)-enabled via `--enable-admission-plugins` and not listed in `--disable-admission-plugins`.\n\nIt applies service account-related defaults and policies to Pods, such as assigning a service account and governing secret references.", + "Risk": "Without **ServiceAccount admission**, Pods may reference unintended secrets and run with unpredictable identities. This enables token misuse and unauthorized API access, facilitating lateral movement and privilege abuse, degrading **confidentiality** and **integrity** of cluster resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#serviceaccount" + ], "Remediation": { "Code": { - "CLI": "--enable-admission-plugins=...,ServiceAccount,...", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-admission-control-plugin-serviceaccount-is-set", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control plane node\n2. Edit the API server static pod manifest:\n sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In the kube-apiserver container flags, add or update the line:\n - --enable-admission-plugins=ServiceAccount \n4. If a --disable-admission-plugins flag exists, ensure ServiceAccount is NOT listed (remove it if present) \n5. Save the file; the kubelet will restart the API server automatically", "Terraform": "" }, "Recommendation": { - "Text": "Enable the ServiceAccount admission control plugin in the API server to manage service accounts and tokens securely.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#serviceaccount" + "Text": "Enable and keep the `ServiceAccount` admission controller active to enforce identity and secret policies.\n- Apply **least privilege**: restrict secrets on each service account\n- Disable token automount where not needed (`automountServiceAccountToken=false`)\n- Isolate secrets by namespace and rotate tokens\n- Keep the API server patched", + "Url": "https://hub.prowler.com/check/apiserver_service_account_plugin" } }, "Categories": [ - "trustboundaries", - "encryption" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_strong_ciphers_only/apiserver_strong_ciphers_only.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_strong_ciphers_only/apiserver_strong_ciphers_only.metadata.json index 2ece38a059..e2b4a8d4d0 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_strong_ciphers_only/apiserver_strong_ciphers_only.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_strong_ciphers_only/apiserver_strong_ciphers_only.metadata.json @@ -1,32 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_strong_ciphers_only", - "CheckTitle": "Ensure that the API Server only makes use of Strong Cryptographic Ciphers", + "CheckTitle": "API Server pod uses only strong cryptographic TLS cipher suites", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "Severity": "medium", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured to only use strong cryptographic ciphers, minimizing the risk of vulnerabilities associated with weaker ciphers. Strong ciphers enhance the security of TLS connections to the API server.", - "Risk": "Using weak ciphers can leave the API server vulnerable to cryptographic attacks, compromising the security of data in transit.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/", + "Description": "**Kubernetes API server** restricts TLS to **strong cipher suites** by configuring `--tls-cipher-suites` to only modern values such as `TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`, and `TLS_CHACHA20_POLY1305_SHA256`", + "Risk": "Permitting weak or mixed cipher suites enables TLS downgrades and cryptanalytic attacks, undermining **confidentiality** and **integrity** of API traffic.\n\nAttackers could intercept or alter requests, steal tokens, and pivot to compromise the control plane.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/#options" + ], "Remediation": { "Code": { - "CLI": "--tls-cipher-suites=TLS_AES_128_GCM_SHA256,...", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-kubelet-only-makes-use-of-strong-cryptographic-ciphers#kubernetes", - "Other": "", + "CLI": "", + "NativeIaC": "```yaml\n# Minimal kube-apiserver manifest snippet enforcing only strong TLS ciphers\napiVersion: v1\nkind: Pod\nmetadata:\n name: kube-apiserver\n namespace: kube-system\nspec:\n containers:\n - name: kube-apiserver\n command:\n - kube-apiserver\n - --tls-cipher-suites=TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256 # FIX: restricts ciphers to strong TLS 1.3 suites only\n```", + "Other": "1. SSH to each control plane node\n2. Open /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[].command, add or replace the flag:\n - --tls-cipher-suites=TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256\n4. Save the file; the kubelet will automatically restart the API server\n5. Repeat on all control plane nodes\n6. Verify the flag is present on the running pod's command args", "Terraform": "" }, "Recommendation": { - "Text": "Restrict the API server to only use strong cryptographic ciphers for enhanced security.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/#options" + "Text": "Limit ciphers to modern AEAD suites and remove legacy entries in `--tls-cipher-suites`.\n- Enforce a high `--tls-min-version` (prefer `VersionTLS13`).\n- Periodically review crypto policy and rotate keys.\n- Apply **defense in depth**: restrict API exposure and require strong client auth.", + "Url": "https://hub.prowler.com/check/apiserver_strong_ciphers_only" } }, "Categories": [ "encryption", - "internet-exposed" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_tls_config/apiserver_tls_config.metadata.json b/prowler/providers/kubernetes/services/apiserver/apiserver_tls_config/apiserver_tls_config.metadata.json index 500a7d3535..86f4290b13 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_tls_config/apiserver_tls_config.metadata.json +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_tls_config/apiserver_tls_config.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "apiserver_tls_config", - "CheckTitle": "Ensure that the --tls-cert-file and --tls-private-key-file arguments are set as appropriate", + "CheckTitle": "API server pod has --tls-cert-file and --tls-private-key-file configured", "CheckType": [], "ServiceName": "apiserver", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesAPIServer", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes API server is configured with TLS for secure communication. The --tls-cert-file and --tls-private-key-file arguments should be set to enable TLS encryption, thereby securing sensitive data transmitted to and from the API server.", - "Risk": "If TLS is not properly configured, the API server communication could be unencrypted, leading to potential data breaches.", - "RelatedUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/", + "Description": "**Kubernetes API server** configuration is checked for explicit TLS settings via `--tls-cert-file` and `--tls-private-key-file`. The presence of both flags indicates HTTPS is configured with a specified certificate and private key for client connections.", + "Risk": "Improper or unmanaged TLS on the API endpoint can cause untrusted certs and verification bypass, enabling MITM to capture admin credentials or tokens and modify requests. This compromises **confidentiality** and **integrity**, and unexpected certificate expiry can affect **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths" + ], "Remediation": { "Code": { - "CLI": "--tls-cert-file= --tls-private-key-file=", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-tls-cert-file-and-tls-private-key-file-arguments-are-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control-plane node\n2. Edit the API server static pod manifest:\n sudo vi /etc/kubernetes/manifests/kube-apiserver.yaml\n3. In spec.containers[0].command, add both flags:\n ```\n - --tls-cert-file=/etc/kubernetes/pki/apiserver.crt\n - --tls-private-key-file=/etc/kubernetes/pki/apiserver.key\n ```\n4. Save the file; the kubelet will automatically restart the API server", "Terraform": "" }, "Recommendation": { - "Text": "Ensure TLS is enabled and properly configured for the API server to secure communications.", - "Url": "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths" + "Text": "Configure the API server to use **TLS** with a valid certificate and key via `--tls-cert-file` and `--tls-private-key-file`.\n\nUse a trusted CA with correct SANs, restrict network access to the endpoint, and automate certificate rotation and expiry monitoring to uphold **defense in depth** and **least privilege**.", + "Url": "https://hub.prowler.com/check/apiserver_tls_config" } }, "Categories": [ - "encryption" + "encryption", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_bind_address/controllermanager_bind_address.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_bind_address/controllermanager_bind_address.metadata.json index 0b2d10bbe0..13057fcf58 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_bind_address/controllermanager_bind_address.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_bind_address/controllermanager_bind_address.metadata.json @@ -1,30 +1,34 @@ { "Provider": "kubernetes", "CheckID": "controllermanager_bind_address", - "CheckTitle": "Ensure that the --bind-address argument is set to 127.0.0.1", + "CheckTitle": "Controller Manager pod is bound to the loopback address 127.0.0.1", "CheckType": [], "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesControllerManager", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the Kubernetes Controller Manager is bound to the loopback address (127.0.0.1) to minimize the cluster's attack surface. Binding to the loopback address ensures that the Controller Manager API service is not exposed to unauthorized network access.", - "Risk": "Binding the Controller Manager to a non-loopback address exposes sensitive health and metrics information without authentication or encryption.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/", + "Description": "**Kubernetes controller manager** uses the **loopback bind address** `127.0.0.1` via `--bind-address` or `--address`, keeping its health, metrics, and debug endpoints reachable only from the host", + "Risk": "Listening on a non-loopback address exposes **health**, **metrics**, and **debug** endpoints to the network, enabling control-plane **reconnaissance** and leakage of internal state. Heavy scraping or profiling can drive resource exhaustion, reducing control-plane **availability** and stability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/" + ], "Remediation": { "Code": { - "CLI": "--bind-address=127.0.0.1", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-bind-address-argument-is-set-to-127001", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control-plane node running the Controller Manager\n2. Edit the static Pod manifest: /etc/kubernetes/manifests/kube-controller-manager.yaml\n3. Under spec.containers[0] command/args, add the flag:\n - --bind-address=127.0.0.1\n4. Save the file; the kubelet will automatically restart the Pod with the new setting", "Terraform": "" }, "Recommendation": { - "Text": "Bind the Controller Manager to the loopback address for enhanced security.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/" + "Text": "Bind to `127.0.0.1` and apply **defense in depth**:\n- Prefer local-only endpoints; avoid `0.0.0.0`\n- Use **TLS** and authentication if exposure is unavoidable\n- Enforce **network segmentation** for control-plane access\n- Disable profiling when not needed; apply **least privilege** for telemetry", + "Url": "https://hub.prowler.com/check/controllermanager_bind_address" } }, "Categories": [ + "cluster-security", "internet-exposed" ], "DependsOn": [], diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_disable_profiling/controllermanager_disable_profiling.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_disable_profiling/controllermanager_disable_profiling.metadata.json index 774f357aaa..069a91aa92 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_disable_profiling/controllermanager_disable_profiling.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_disable_profiling/controllermanager_disable_profiling.metadata.json @@ -1,31 +1,34 @@ { "Provider": "kubernetes", "CheckID": "controllermanager_disable_profiling", - "CheckTitle": "Ensure that the --profiling argument is set to false", + "CheckTitle": "Controller Manager pod has --profiling=false configured", "CheckType": [], "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesControllerManager", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that profiling is disabled in the Kubernetes Controller Manager, reducing the potential attack surface.", - "Risk": "Enabling profiling can expose detailed system and program information, which could be exploited if accessed by unauthorized users.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/", + "Description": "**Kubernetes Controller Manager** is evaluated for the `--profiling` argument. `--profiling=false` disables runtime profiling; absence or a different value means profiling is enabled.", + "Risk": "With profiling enabled, debug endpoints expose **runtime internals** (stacks, memory, file paths), weakening confidentiality. Abusing profiling can raise CPU/memory use and degrade **availability**. Detailed insights accelerate reconnaissance and can aid escalation when combined with RBAC gaps.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/#options" + ], "Remediation": { "Code": { - "CLI": "--profiling=false", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-profiling-argument-is-set-to-false", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control plane node\n2. Open /etc/kubernetes/manifests/kube-controller-manager.yaml\n3. Under spec.containers[0].command add the line: - --profiling=false\n4. Save the file; kubelet will automatically restart the static Pod\n5. Verify on the node: ps -ef | grep kube-controller-manager | grep -- --profiling=false", "Terraform": "" }, "Recommendation": { - "Text": "Disable profiling in the Kubernetes Controller Manager for enhanced security.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/#options" + "Text": "Set `--profiling=false` on the **controller manager** to remove debug endpoints.\n\n*If profiling is needed temporarily*:\n- Limit access using **least privilege** and network controls\n- Use isolated environments and monitor closely\n- Disable promptly to uphold **defense in depth**", + "Url": "https://hub.prowler.com/check/controllermanager_disable_profiling" } }, "Categories": [ - "trustboundaries" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_garbage_collection/controllermanager_garbage_collection.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_garbage_collection/controllermanager_garbage_collection.metadata.json index c37f510863..a7a7429be6 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_garbage_collection/controllermanager_garbage_collection.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_garbage_collection/controllermanager_garbage_collection.metadata.json @@ -1,27 +1,31 @@ { "Provider": "kubernetes", "CheckID": "controllermanager_garbage_collection", - "CheckTitle": "Ensure that the --terminated-pod-gc-threshold argument is set as appropriate", + "CheckTitle": "Controller Manager pod does not use the default --terminated-pod-gc-threshold value", "CheckType": [], "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesControllerManager", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "Activate garbage collector on pod termination, as appropriate. Garbage collection is crucial for maintaining resource availability and performance. The default threshold for garbage collection is 12,500 terminated pods, which may be too high for some systems. Adjusting this threshold based on system resources and performance tests is recommended.", - "Risk": "A high threshold for garbage collection can lead to degraded performance and resource exhaustion. In extreme cases, it might cause system crashes or prolonged unavailability.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-garbage-collection", + "Description": "**Kubernetes controller manager** terminated Pod garbage collection threshold is evaluated. The finding highlights use of the default `--terminated-pod-gc-threshold=12500` instead of a value tuned to cluster size and workload churn. The threshold controls when terminated Pods are automatically removed.", + "Risk": "Retaining too many terminated Pods strains **API server**, **etcd**, and controller memory, reducing control-plane **availability**. Effects include slow list/watch operations, lagging schedulers, timeouts, and, in worst cases, controller crashes or admin-plane DoS.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-garbage-collection", + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/" + ], "Remediation": { "Code": { - "CLI": "--terminated-pod-gc-threshold=10", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-terminated-pod-gc-threshold-argument-is-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control-plane node\n2. Edit the static Pod manifest: /etc/kubernetes/manifests/kube-controller-manager.yaml\n3. Under the kube-controller-manager container args/command, set: --terminated-pod-gc-threshold=10 (any value not equal to 12500)\n4. Save the file; the kubelet will automatically restart the controller-manager\n5. Repeat on all control-plane nodes if using HA", "Terraform": "" }, "Recommendation": { - "Text": "Review and adjust the --terminated-pod-gc-threshold argument in the kube-controller-manager to ensure efficient garbage collection and optimal resource utilization.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/" + "Text": "Set a **lower, context-appropriate** `--terminated-pod-gc-threshold` to match cluster scale and pod churn, preserving control-plane capacity. Monitor garbage collection and control-plane metrics and adjust proactively. Use `ttlSecondsAfterFinished` for Jobs to minimize terminated Pods.", + "Url": "https://hub.prowler.com/check/controllermanager_garbage_collection" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_root_ca_file_set/controllermanager_root_ca_file_set.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_root_ca_file_set/controllermanager_root_ca_file_set.metadata.json index 5b190229f1..d5388595bf 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_root_ca_file_set/controllermanager_root_ca_file_set.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_root_ca_file_set/controllermanager_root_ca_file_set.metadata.json @@ -1,32 +1,35 @@ { "Provider": "kubernetes", "CheckID": "controllermanager_root_ca_file_set", - "CheckTitle": "Ensure that the --root-ca-file argument is set as appropriate", + "CheckTitle": "Controller Manager pod has --root-ca-file argument set", "CheckType": [], "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesControllerManager", + "Severity": "critical", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the Kubernetes Controller Manager is configured with the --root-ca-file argument set to a certificate bundle file, allowing pods to verify the API server's serving certificate.", - "Risk": "Not setting the root CA file can expose pods to man-in-the-middle attacks due to unverified TLS connections to the API server.", - "RelatedUrl": "https://kubernetes.io/docs/setup/best-practices/certificates/", + "Description": "**Kubernetes Controller Manager** uses `--root-ca-file` to reference a certificate bundle so pods get a `ca.crt` for validating the API server's TLS certificate.", + "Risk": "Without a configured root CA, pods cannot reliably verify the API server, enabling on-path spoofing. This exposes API traffic and service account tokens, allowing session hijack, data exfiltration, and malicious config changes-compromising confidentiality and integrity, and potentially disrupting availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths" + ], "Remediation": { "Code": { - "CLI": "--root-ca-file=/path/to/ca-file", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-root-ca-file-argument-is-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to a control-plane node with sudo privileges\n2. Edit the static Pod manifest: /etc/kubernetes/manifests/kube-controller-manager.yaml\n3. In the kube-controller-manager container command list, add this flag:\n - --root-ca-file=/etc/kubernetes/pki/ca.crt\n4. Save the file; kubelet will automatically restart the Pod", "Terraform": "" }, "Recommendation": { - "Text": "Configure the Controller Manager with a root CA file to enhance security for pods communicating with the API server.", - "Url": "https://kubernetes.io/docs/setup/best-practices/certificates/#certificate-paths" + "Text": "Set a trusted CA bundle via `--root-ca-file` on the controller manager to ensure verified TLS for in-cluster API calls. Use a cluster-controlled CA, rotate and monitor certificates, and keep the bundle aligned with the API server chain. Apply **defense in depth** and **least privilege** for service accounts.", + "Url": "https://hub.prowler.com/check/controllermanager_root_ca_file_set" } }, "Categories": [ "encryption", - "internet-exposed" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_rotate_kubelet_server_cert/controllermanager_rotate_kubelet_server_cert.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_rotate_kubelet_server_cert/controllermanager_rotate_kubelet_server_cert.metadata.json index a9d97dd639..9c58b4f3d3 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_rotate_kubelet_server_cert/controllermanager_rotate_kubelet_server_cert.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_rotate_kubelet_server_cert/controllermanager_rotate_kubelet_server_cert.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "controllermanager_rotate_kubelet_server_cert", - "CheckTitle": "Ensure that the RotateKubeletServerCertificate argument is set to true", + "CheckTitle": "Controller Manager pod has RotateKubeletServerCertificate set to true", "CheckType": [], "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesControllerManager", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes Controller Manager is configured with the RotateKubeletServerCertificate argument set to true, enabling automated rotation of kubelet server certificates.", - "Risk": "Not enabling kubelet server certificate rotation could lead to downtime due to expired certificates.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/tls/certificate-rotation/", + "Description": "**Kubernetes controller manager** configuration includes the `RotateKubeletServerCertificate=true` feature gate for automatic rotation of **kubelet server certificates**", + "Risk": "Without **certificate rotation**, kubelet HTTPS endpoints can use expired or long-lived certs, triggering TLS failures and operational gaps. Teams may bypass verification, enabling **MitM** and tampering. This harms **availability** and **integrity**, and extends exposure if a private key is compromised.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/tls/certificate-rotation/#understanding-the-certificate-rotation-configuration" + ], "Remediation": { "Code": { - "CLI": "--feature-gates='RotateKubeletServerCertificate=true'", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-rotatekubeletservercertificate-argument-is-set-to-true-for-controller-manager#kubernetes", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to a control-plane node and open the controller manager static pod manifest:\n - /etc/kubernetes/manifests/kube-controller-manager.yaml\n2. In spec.containers[0].command, add or update this flag:\n ```\n --feature-gates=RotateKubeletServerCertificate=true\n ```\n3. Save the file; the kubelet will automatically restart the pod with the updated setting.", "Terraform": "" }, "Recommendation": { - "Text": "Enable kubelet server certificate rotation in the Controller Manager for automated certificate management.", - "Url": "https://kubernetes.io/docs/tasks/tls/certificate-rotation/#understanding-the-certificate-rotation-configuration" + "Text": "Enable `RotateKubeletServerCertificate=true` on the controller manager and ensure kubelets participate in rotation.\n\nUse short-lived certs, automated renewal, and strict TLS validation to maintain **availability**, protect **integrity**, and uphold **cryptographic hygiene**. Avoid insecure fallbacks.", + "Url": "https://hub.prowler.com/check/controllermanager_rotate_kubelet_server_cert" } }, "Categories": [ - "encryption" + "encryption", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_credentials/controllermanager_service_account_credentials.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_credentials/controllermanager_service_account_credentials.metadata.json index 0921a6fb79..c9a71ccd36 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_credentials/controllermanager_service_account_credentials.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_credentials/controllermanager_service_account_credentials.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "controllermanager_service_account_credentials", - "CheckTitle": "Ensure that the --use-service-account-credentials argument is set to true", + "CheckTitle": "Controller Manager pod has --use-service-account-credentials=true", "CheckType": [], "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesControllerManager", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the Kubernetes Controller Manager is configured to use individual service account credentials for each controller, enhancing the security and role separation within the Kubernetes system.", - "Risk": "Not using individual service account credentials can lead to overly broad permissions and potential security risks.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/", + "Description": "Evaluates whether the **Kubernetes controller manager** uses per-controller service account credentials via `--use-service-account-credentials=true`, meaning each controller runs with its own identity rather than a shared credential.", + "Risk": "Without per-controller credentials, one token can grant broad controller privileges. Compromise or misuse enables unauthorized state changes, data exposure, and lateral movement, while reducing audit granularity-impacting confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/#options" + ], "Remediation": { "Code": { - "CLI": "--use-service-account-credentials=true", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-use-service-account-credentials-argument-is-set-to-true", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each control-plane node\n2. Edit the static Pod manifest: /etc/kubernetes/manifests/kube-controller-manager.yaml\n3. Under spec.containers[0].command, add a new item: --use-service-account-credentials=true\n4. Save the file; the kubelet will automatically restart the controller-manager", "Terraform": "" }, "Recommendation": { - "Text": "Configure the Controller Manager to use individual service account credentials for enhanced security and role separation.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/#options" + "Text": "Enable `--use-service-account-credentials=true` and enforce **least privilege**: assign a dedicated service account per controller with minimal RBAC, limit token scope/lifetime, and monitor controller actions. This upholds **separation of duties** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/controllermanager_service_account_credentials" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_private_key_file/controllermanager_service_account_private_key_file.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_private_key_file/controllermanager_service_account_private_key_file.metadata.json index 56b3548a16..286b646680 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_private_key_file/controllermanager_service_account_private_key_file.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_private_key_file/controllermanager_service_account_private_key_file.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "controllermanager_service_account_private_key_file", - "CheckTitle": "Ensure that the --service-account-private-key-file argument is set as appropriate", + "CheckTitle": "Controller Manager pod has the --service-account-private-key-file argument set", "CheckType": [], "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesControllerManager", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes Controller Manager is configured with the --service-account-private-key-file argument set to the private key file for service accounts.", - "Risk": "Not setting a private key file for service accounts can hinder the ability to securely rotate service account tokens.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/", + "Description": "**Kubernetes controller manager** uses a **service account signing key** configured via `--service-account-private-key-file`.\n\nThe evaluation identifies whether this argument is present, indicating the component can sign service account tokens.", + "Risk": "Without a configured signing key, the token controller can't mint service account tokens, breaking pod-to-API auth and controller operations (**availability**). Inability to rotate keys prolongs validity of stolen or stale tokens, weakening **integrity** and **confidentiality**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/#token-controller" + ], "Remediation": { "Code": { - "CLI": "--service-account-private-key-file=/path/to/sa-key-file", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-service-account-private-key-file-argument-is-set-as-appropriate", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node\n2. Edit /etc/kubernetes/manifests/kube-controller-manager.yaml\n3. Under containers[].command, add: - --service-account-private-key-file=/etc/kubernetes/pki/sa.key\n4. Save the file; the kubelet will restart the kube-controller-manager pod automatically", "Terraform": "" }, "Recommendation": { - "Text": "Configure the Controller Manager with a private key file for service accounts to maintain security and enable token rotation.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/#token-controller" + "Text": "Set a dedicated **signing key** using `--service-account-private-key-file`, or adopt an approved external signer.\n\nApply **least privilege** to key access, enforce **regular rotation** and rollover, separate **signing/verification** duties, and prefer short-lived tokens with strict **RBAC**.", + "Url": "https://hub.prowler.com/check/controllermanager_service_account_private_key_file" } }, "Categories": [ - "encryption" + "cluster-security", + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/core/core_minimize_admission_hostport_containers/core_minimize_admission_hostport_containers.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_admission_hostport_containers/core_minimize_admission_hostport_containers.metadata.json index 56cb60c49d..5fa960dffa 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_admission_hostport_containers/core_minimize_admission_hostport_containers.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_admission_hostport_containers/core_minimize_admission_hostport_containers.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_admission_hostport_containers", - "CheckTitle": "Minimize the admission of containers which use HostPorts", + "CheckTitle": "Pod does not use HostPorts", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers that require the use of HostPorts. This helps maintain network policy controls and reduce security risks.", - "Risk": "Permitting containers with HostPorts can bypass network policy controls, increasing the risk of unauthorized network access.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "**Kubernetes Pods** are inspected for any container declaring `ports[].hostPort`. The finding highlights workloads that bind container ports directly to the node's network stack via **HostPorts**.", + "Risk": "Using **HostPorts** exposes Pods on node IPs outside centralized Service/Ingress controls. Attackers can directly probe and access workloads (**confidentiality/integrity**). Port conflicts or saturation on nodes can disrupt traffic (**availability**). Network segmentation and some policies may be less effective.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_25#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Open your Kubernetes Dashboard and go to Workloads\n2. Select the affected Deployment/DaemonSet/StatefulSet (or Pod) and click Edit\n3. In the YAML, remove every `hostPort` field under `spec.template.spec.containers[].ports[]` (or `spec.containers[]` for a standalone Pod)\n4. Save the changes; allow the workload to restart\n5. Verify the new Pods have no `hostPort` defined", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"nginx\"\n # Critical: do NOT set host_port; omitting it ensures no host port is used\n port { container_port = 80 }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Limit the use of HostPorts in Kubernetes containers to maintain network security.", - "Url": "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + "Text": "Avoid `hostPort`; publish services via **ClusterIP** with **Ingress/LoadBalancer**. Enforce admission policies to deny `hostPort` by default, permitting only a narrowly justified allowlist. Apply **least privilege** network rules, segment nodes, and monitor for unexpected host port bindings as **defense in depth**.", + "Url": "https://hub.prowler.com/check/core_minimize_admission_hostport_containers" } }, "Categories": [ - "internet-exposed" + "internet-exposed", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/core/core_minimize_admission_windows_hostprocess_containers/core_minimize_admission_windows_hostprocess_containers.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_admission_windows_hostprocess_containers/core_minimize_admission_windows_hostprocess_containers.metadata.json index a153216e9e..8e0b036fb7 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_admission_windows_hostprocess_containers/core_minimize_admission_windows_hostprocess_containers.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_admission_windows_hostprocess_containers/core_minimize_admission_windows_hostprocess_containers.metadata.json @@ -1,27 +1,31 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_admission_windows_hostprocess_containers", - "CheckTitle": "Minimize the admission of Windows HostProcess Containers", + "CheckTitle": "Pod does not allow Windows HostProcess containers", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of Windows containers with the hostProcess flag set to true, thus reducing the risk of privilege escalation and security breaches.", - "Risk": "Allowing Windows containers with hostProcess can lead to increased security risks due to privileged access to Windows nodes.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/", + "Description": "**Kubernetes Pods** are evaluated for Windows settings where `securityContext.windowsOptions.hostProcess` is set to `true`, indicating they can run **Windows HostProcess containers**.", + "Risk": "Enabling **HostProcess** grants containers direct access to the Windows node, eroding isolation. Attackers can read node data, tamper with services, capture credentials, and pivot across the cluster, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kyverno.io/policies/pod-security/baseline/disallow-host-process/disallow-host-process/", + "https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_1#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Open your Kubernetes GUI (for example, Kubernetes Dashboard)\n2. Go to Workloads > Deployments/DaemonSets/StatefulSets (edit the owner of the Pod, not the Pod itself)\n3. Select the workload that creates the failing Pod and click Edit\n4. For each affected container, set: securityContext.windowsOptions.hostProcess to false (or remove the hostProcess field)\n5. Save the change to trigger a rollout; new Pods will be created without HostProcess\n6. Verify new Pods no longer contain securityContext.windowsOptions.hostProcess: true", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"busybox\"\n security_context {\n windows_options {\n host_process = false # critical: disables Windows HostProcess for this container\n }\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the use of Windows HostProcess containers unless essential for their operation.", - "Url": "https://kubernetes.io/docs/tasks/configure-pod-container/create-hostprocess-pod/" + "Text": "Disallow `hostProcess:true` by default using policy-based admission aligned with **Pod Security Standards**. Permit only in tightly controlled contexts; apply **least privilege**, dedicated namespaces, and restricted service accounts; enforce **separation of duties** and monitor usage.", + "Url": "https://hub.prowler.com/check/core_minimize_admission_windows_hostprocess_containers" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/core/core_minimize_allowPrivilegeEscalation_containers/core_minimize_allowPrivilegeEscalation_containers.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_allowPrivilegeEscalation_containers/core_minimize_allowPrivilegeEscalation_containers.metadata.json index a4bea70680..80f8e77079 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_allowPrivilegeEscalation_containers/core_minimize_allowPrivilegeEscalation_containers.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_allowPrivilegeEscalation_containers/core_minimize_allowPrivilegeEscalation_containers.metadata.json @@ -1,27 +1,32 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_allowPrivilegeEscalation_containers", - "CheckTitle": "Minimize the admission of containers with allowPrivilegeEscalation", + "CheckTitle": "Pod does not allow privilege escalation in any container", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers that have the allowPrivilegeEscalation flag set to true, preventing processes within containers from gaining additional privileges.", - "Risk": "Allowing containers with allowPrivilegeEscalation can lead to elevated privileges within the container's context, posing a security risk.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "**Kubernetes Pods** are evaluated for containers that enable `allowPrivilegeEscalation`. The finding highlights pods where any container permits processes to gain extra privileges; pods whose containers set `allowPrivilegeEscalation: false` are noted as not allowing escalation.", + "Risk": "Allowing privilege escalation lets processes acquire elevated rights, undermining container isolation. Attackers can abuse setuid paths and capabilities to tamper with workloads (**integrity**), read sensitive data (**confidentiality**), pivot within the cluster, or disrupt services (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/", + "https://support.icompaas.com/support/solutions/articles/62000234205-minimize-the-admission-of-containers-with-allowprivilegeescalation", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_19#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Open your Kubernetes Dashboard (or your cloud provider's Kubernetes console) and locate the workload managing the failing Pod (Deployment/StatefulSet/DaemonSet)\n2. Click Edit to modify the manifest (YAML)\n3. For each container with securityContext.allowPrivilegeEscalation: true, set it to false (or add allowPrivilegeEscalation: false under securityContext)\n4. Save/Apply the changes to trigger a rollout\n5. Verify new Pods have securityContext.allowPrivilegeEscalation set to false", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"main\" {\n metadata {\n name = \"\"\n }\n spec {\n container {\n name = \"app\"\n image = \"nginx\"\n security_context {\n allow_privilege_escalation = false # Critical: explicitly disable privilege escalation for the container\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the use of allowPrivilegeEscalation in containers through admission control policies.", - "Url": "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/" + "Text": "Set `allowPrivilegeEscalation: false` by default and apply **least privilege**:\n- run as non-root; drop caps (`drop: [\"ALL\"]`)\n- avoid `privileged`; use `readOnlyRootFilesystem`\n- enforce via namespace admission policies (e.g., PSA/OPA) and monitor exceptions", + "Url": "https://hub.prowler.com/check/core_minimize_allowPrivilegeEscalation_containers" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/core/core_minimize_containers_added_capabilities/core_minimize_containers_added_capabilities.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_containers_added_capabilities/core_minimize_containers_added_capabilities.metadata.json index ecf3bf07b2..622199cade 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_containers_added_capabilities/core_minimize_containers_added_capabilities.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_containers_added_capabilities/core_minimize_containers_added_capabilities.metadata.json @@ -1,27 +1,30 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_containers_added_capabilities", - "CheckTitle": "Minimize the admission of containers with added capabilities", + "CheckTitle": "Pod has no containers with added capabilities", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers with capabilities assigned beyond the default set, mitigating the risks of container breakout attacks.", - "Risk": "Allowing containers with additional capabilities increases the risk of security breaches and container breakout attacks.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "Kubernetes Pods and containers are evaluated for **added Linux capabilities** via `capabilities.add` in their security context; presence of added entries indicates elevated privileges beyond defaults.", + "Risk": "Extra capabilities expand the container's kernel-level permissions, enabling actions like raw socket use, file ownership changes, and mount operations. Compromise could enable node access, lateral movement, or tampering with workloads, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "kubectl patch deployment -p '{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"\",\"securityContext\":{\"capabilities\":{\"add\":[]}}}]}}}}'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Open the manifest for the workload creating the Pod (e.g., Deployment/StatefulSet/DaemonSet)\n2. In spec.template.spec.containers[*].securityContext.capabilities, delete all 'add' entries (remove the entire 'add' list)\n3. Save and apply the change (kubectl apply or use your GitOps pipeline); the controller will roll out updated Pods\n4. If it is a standalone Pod, delete and recreate it without the capabilities.add field", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n container {\n name = \"\"\n image = \"nginx\"\n # Critical: do not set security_context.capabilities.add\n # This ensures no added Linux capabilities, making the check PASS.\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the addition of extra capabilities to containers through admission control policies.", - "Url": "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + "Text": "Apply **least privilege**: require containers to `drop: ALL` and avoid `capabilities.add` except when strictly justified (e.g., `NET_BIND_SERVICE`). Enforce with **admission policies** and separation of duties. Combine with **seccomp/AppArmor** and non-root execution for **defense in depth**.", + "Url": "https://hub.prowler.com/check/core_minimize_containers_added_capabilities" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/core/core_minimize_containers_capabilities_assigned/core_minimize_containers_capabilities_assigned.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_containers_capabilities_assigned/core_minimize_containers_capabilities_assigned.metadata.json index 155a9179bc..4cf909f388 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_containers_capabilities_assigned/core_minimize_containers_capabilities_assigned.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_containers_capabilities_assigned/core_minimize_containers_capabilities_assigned.metadata.json @@ -1,27 +1,30 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_containers_capabilities_assigned", - "CheckTitle": "Minimize the admission of containers with capabilities assigned", + "CheckTitle": "Pod containers have no added Linux capabilities and include capability drops when capabilities are defined", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers with Linux capabilities assigned, adhering to the principle of least privilege and reducing the risk of privilege escalation.", - "Risk": "Assigning unnecessary Linux capabilities to containers increases the risk of privilege escalation and security breaches.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "**Kubernetes Pods** are inspected for container **Linux capabilities**. A finding occurs when any container sets capabilities in `add` or does not fully `drop` them (e.g., missing `ALL`), indicating capabilities are assigned instead of removed.", + "Risk": "Retained or added **Linux capabilities** enable privilege escalation and container escape.\n- Confidentiality: packet capture and secret access\n- Integrity: filesystem mounts or process tampering\n- Availability: killing services or altering networking", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_34#kubernetes", - "Other": "", - "Terraform": "" + "CLI": "kubectl patch deployment -n --type=merge -p '{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"\",\"securityContext\":{\"capabilities\":{\"drop\":[\"ALL\"],\"add\":[]}}}]}}}}'", + "NativeIaC": "", + "Other": "1. Open your Kubernetes UI (e.g., Kubernetes Dashboard or your cloud provider's console)\n2. Navigate to the workload (Deployment/StatefulSet/DaemonSet) that runs the failing Pod\n3. Click Edit YAML (or equivalent)\n4. For each affected container, set:\n - spec.template.spec.containers[].securityContext.capabilities.drop: [\"ALL\"]\n - Ensure spec.template.spec.containers[].securityContext.capabilities.add is removed or set to an empty list\n5. Save to apply and trigger a rollout", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"nginx:stable-alpine\"\n security_context {\n capabilities {\n drop = [\"ALL\"] # Critical: drop all Linux capabilities to satisfy the check\n # No 'add' specified to ensure no capabilities are added\n }\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the assignment of Linux capabilities to containers unless essential for their operation.", - "Url": "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + "Text": "Apply **least privilege**: drop `ALL` capabilities and avoid using `add`.\n\nOnly reintroduce a minimal capability when absolutely required, and isolate such pods via defense-in-depth: strict RBAC, `seccomp` RuntimeDefault, AppArmor, network policies, dedicated namespaces/nodes, and admission controls to enforce policy.", + "Url": "https://hub.prowler.com/check/core_minimize_containers_capabilities_assigned" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostIPC_containers/core_minimize_hostIPC_containers.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_hostIPC_containers/core_minimize_hostIPC_containers.metadata.json index af9bfab2cd..5304559809 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_hostIPC_containers/core_minimize_hostIPC_containers.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_hostIPC_containers/core_minimize_hostIPC_containers.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_hostIPC_containers", - "CheckTitle": "Minimize the admission of containers wishing to share the host IPC namespace", + "CheckTitle": "Pod does not use the host IPC namespace", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers that share the host's IPC namespace. Containers with hostIPC can interact with processes outside of the container, potentially leading to security risks.", - "Risk": "Allowing containers to share the host's IPC namespace without strict control can lead to security risks and potential privilege escalations.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "**Kubernetes pods** are evaluated for use of the host's IPC namespace via the `hostIPC` setting. Workloads declaring `hostIPC: true` share node IPC resources (shared memory, semaphores, message queues) instead of isolated container IPC.", + "Risk": "Sharing the host IPC namespace erodes isolation, exposing host shared memory and semaphores. A compromised pod could snoop or tamper with IPC objects, leading to data disclosure, integrity violations, privilege escalation, and lateral movement across workloads.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000234627-minimize-the-admission-of-containers-wishing-to-share-the-host-ipc-namespace", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_3#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Open your cluster UI (Kubernetes Dashboard or cloud provider console) and locate the failing workload (Pod/Deployment/StatefulSet)\n2. Click Edit YAML/Manifest\n3. Set the field to disable host IPC:\n - For a Pod: spec.hostIPC: false (or remove hostIPC if present)\n - For controllers: spec.template.spec.hostIPC: false (or remove hostIPC if present)\n4. Save the change to trigger a rollout\n5. If it is a standalone Pod (not managed by a controller), delete and recreate the Pod so the new spec takes effect", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n host_ipc = false # Critical: disables host IPC namespace to pass the check\n\n container {\n name = \"app\"\n image = \"nginx\"\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the use of hostIPC in containers through admission control policies.", - "Url": "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + "Text": "Disallow `hostIPC` with **Pod Security Admission** enforcing **Pod Security Standards** (Baseline/Restricted) or equivalent policy engines. Apply **least privilege** and defense in depth: keep IPC namespaces isolated, grant tightly scoped exceptions only, and prefer app-level messaging or Services over host IPC.", + "Url": "https://hub.prowler.com/check/core_minimize_hostIPC_containers" } }, "Categories": [ - "container-security" + "container-security", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostNetwork_containers/core_minimize_hostNetwork_containers.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_hostNetwork_containers/core_minimize_hostNetwork_containers.metadata.json index 204eb96a61..8f9574e7d6 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_hostNetwork_containers/core_minimize_hostNetwork_containers.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_hostNetwork_containers/core_minimize_hostNetwork_containers.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_hostNetwork_containers", - "CheckTitle": "Minimize the admission of containers wishing to share the host network namespace", + "CheckTitle": "Pod does not use hostNetwork", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers that share the host's network namespace. Containers with hostNetwork can access local network traffic and other pods, potentially leading to security risks.", - "Risk": "Allowing containers to share the host's network namespace without strict control can lead to security risks and potential network breaches.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "**Kubernetes Pods** configured with `hostNetwork: true` are identified, meaning they share the node's network namespace and use the host's IP stack, interfaces, and ports.", + "Risk": "Using the **host network namespace** exposes node-local interfaces and traffic to the pod. A compromise can enable packet capture and request spoofing (**C/I**), access to node services (e.g., kubelet), and port binding conflicts, causing outages (**A**) and enabling **lateral movement** across the cluster.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_4#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Open Kubernetes Dashboard\n2. For controller-managed workloads (Deployment/DaemonSet/StatefulSet):\n - Go to Workloads > select the workload > Edit\n - In the Pod template, set spec.template.spec.hostNetwork to false or remove the field\n - Save to roll out new Pods\n3. For standalone Pods:\n - Go to Workloads > Pods > select the Pod > Delete\n - Click Create > upload the Pod YAML without hostNetwork (or with hostNetwork: false) and create the Pod", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n host_network = false # Critical: disables hostNetwork so the Pod passes the check\n container {\n name = \"ct\"\n image = \"nginx\"\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the use of hostNetwork in containers through admission control policies.", - "Url": "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + "Text": "Disallow `hostNetwork` by default. Enforce **least privilege** with admission policies that block it, allowing narrowly scoped exceptions only for trusted system workloads. Prefer standard pod networking with **NetworkPolicies**, and isolate node services for **defense in depth**.", + "Url": "https://hub.prowler.com/check/core_minimize_hostNetwork_containers" } }, "Categories": [ - "container-security" + "container-security", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/core/core_minimize_hostPID_containers/core_minimize_hostPID_containers.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_hostPID_containers/core_minimize_hostPID_containers.metadata.json index 6ceeb593c4..9adeee63d8 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_hostPID_containers/core_minimize_hostPID_containers.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_hostPID_containers/core_minimize_hostPID_containers.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_hostPID_containers", - "CheckTitle": "Minimize the admission of containers wishing to share the host process ID namespace", + "CheckTitle": "Pod does not use the host PID namespace", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers that share the host's process ID namespace. Containers with hostPID can inspect and interact with processes outside of the container, potentially leading to privilege escalation.", - "Risk": "Allowing containers to share the host's PID namespace without strict control can lead to security risks and potential privilege escalations.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "**Kubernetes Pods** configured with `hostPID: true` are identified, indicating the container shares the node's **host PID namespace**.", + "Risk": "Sharing the **host PID namespace** erodes isolation: containers can list host processes and read `/proc` metadata, enabling **credential exposure**, **privilege escalation**, and **lateral movement**. Limited process interaction can also threaten **integrity** and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_1#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Identify the workload that owns the failing Pod (Deployment/DaemonSet/StatefulSet/Job) or confirm it is a standalone Pod\n2. Edit the manifest of the owning workload (or the Pod) and set/remove the field so it is not true:\n - In Pod spec: set `hostPID: false` or delete the `hostPID` line\n - For controllers: set `spec.template.spec.hostPID: false` or delete the line\n3. Apply the change (kubectl apply) and allow the workload to restart Pods so they no longer use hostPID", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n host_pid = false # Critical: disables host PID namespace to pass the check\n container {\n name = \"app\"\n image = \"nginx\"\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the use of hostPID in containers through admission control policies.", - "Url": "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + "Text": "Disallow `hostPID` for application Pods via **admission policies** aligned to **Pod Security Standards (Baseline/Restricted)**. Allow only for tightly controlled system workloads. Apply **least privilege**, isolate such Pods on dedicated nodes, and favor debug/observability methods that avoid host namespace sharing.", + "Url": "https://hub.prowler.com/check/core_minimize_hostPID_containers" } }, "Categories": [ - "container-security" + "container-security", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/core/core_minimize_net_raw_capability_admission/core_minimize_net_raw_capability_admission.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_net_raw_capability_admission/core_minimize_net_raw_capability_admission.metadata.json index 3efdaafc58..f60aa72144 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_net_raw_capability_admission/core_minimize_net_raw_capability_admission.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_net_raw_capability_admission/core_minimize_net_raw_capability_admission.metadata.json @@ -1,27 +1,36 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_net_raw_capability_admission", - "CheckTitle": "Minimize the admission of containers with the NET_RAW capability", + "CheckTitle": "Pod containers do not have the NET_RAW capability", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers with the potentially dangerous NET_RAW capability, which can be exploited by malicious containers.", - "Risk": "Allowing containers with NET_RAW capability increases the risk of network attacks and privilege escalation.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/configure-pod-container/security-context", + "Description": "**Kubernetes pods** where any container's security context adds the `NET_RAW` Linux capability are identified.\n\nThe inspection evaluates container `securityContext.capabilities.add` entries to detect explicit requests for `NET_RAW`.", + "Risk": "Granting **NET_RAW** enables raw sockets for packet crafting and sniffing, undermining **confidentiality** and **integrity**. Attackers can run ARP/DNS spoofing, pivot or scan inside the cluster, bypass service isolation, exfiltrate data, and impact **availability** through network abuse.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000234711-minimize-the-admission-of-containers-with-the-net-raw-capability", + "https://dev.to/castai/kubernetes-security-10-best-practices-from-the-industry-and-community-1bp6?comments_sort=latest", + "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-capabilities-for-a-container", + "https://istio.io/latest/docs/setup/additional-setup/pod-security-admission/", + "https://github.com/aws-samples/k8s-psa-pss-testing", + "https://kubernetes.io/docs/tasks/configure-pod-container/security-context", + "https://kubernetes.io/docs/concepts/security/pod-security-admission/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_6#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Identify the affected workload (Deployment/StatefulSet/Pod) and open its spec for edit (e.g., kubectl edit deployment )\n2. In each container under: spec.template.spec.containers[].securityContext.capabilities.add, remove the entry NET_RAW (or remove the entire add field if it only contains NET_RAW)\n3. Save the changes to trigger a rollout (for Pod manifests, kubectl apply -f )\n4. Recreate any standalone Pods if needed so the updated spec takes effect", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"nginx\"\n security_context {\n capabilities {\n drop = [\"NET_RAW\"] # Critical: ensures the container is not granted NET_RAW\n }\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the use of NET_RAW capability through admission control policies.", - "Url": "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/#set-capabilities-for-a-container" + "Text": "Apply **least privilege**: avoid adding `NET_RAW` and drop unnecessary Linux capabilities by default. Use cluster-wide **admission policies** to block requests for `NET_RAW`. When strictly required, isolate the workload, restrict egress with network controls, and audit capability use as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/core_minimize_net_raw_capability_admission" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/core/core_minimize_privileged_containers/core_minimize_privileged_containers.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_privileged_containers/core_minimize_privileged_containers.metadata.json index a73c3a95b5..37331878d0 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_privileged_containers/core_minimize_privileged_containers.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_privileged_containers/core_minimize_privileged_containers.metadata.json @@ -1,27 +1,33 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_privileged_containers", - "CheckTitle": "Minimize the admission of privileged containers", + "CheckTitle": "Pod does not contain a privileged container", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of privileged containers, which have access to all Linux Kernel capabilities and devices. The use of privileged containers should be controlled and restricted to specific use-cases.", - "Risk": "Permitting privileged containers by default can lead to security vulnerabilities as these containers have elevated privileges equivalent to the host.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "**Kubernetes Pods** are evaluated for containers configured with `securityContext.privileged: true`, indicating execution in **privileged mode**.", + "Risk": "**Privileged containers** can control the host and bypass isolation, enabling:\n- Secret theft (confidentiality)\n- Workload/node tampering (integrity)\n- Service disruption (availability)\nCompromise of one pod can drive lateral movement across the cluster.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/security-context/", + "https://kubernetes.io/docs/tasks/configure-pod-container/enforce-standards-admission-controller/", + "https://kubernetes.io/docs/setup/best-practices/enforcing-pod-security-standards/", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_2#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Open your Kubernetes UI (e.g., cloud provider console or Dashboard)\n2. Navigate to the failing workload (Deployment/StatefulSet/DaemonSet) or Pod and choose Edit YAML/Manifest\n3. Set securityContext.privileged: false for each affected container:\n - For controllers: spec.template.spec.containers[].securityContext.privileged: false\n - For standalone Pods: spec.containers[].securityContext.privileged: false\n4. Save changes (controllers will roll out new Pods). If it is a standalone Pod, delete and recreate it to apply the change", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"\"\n security_context {\n privileged = false # Critical: disables privileged mode to pass the check\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the use of privileged containers through admission control policies.", - "Url": "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + "Text": "Block `privileged: true` using **Pod Security Admission** at `restricted`. Apply **least privilege**:\n- Run unprivileged; set `allowPrivilegeEscalation: false`\n- Drop capabilities; avoid host access\n- Restrict who can deploy privileged pods with **RBAC**\n- Use short-lived, audited exceptions only when strictly required", + "Url": "https://hub.prowler.com/check/core_minimize_privileged_containers" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/core/core_minimize_root_containers_admission/core_minimize_root_containers_admission.metadata.json b/prowler/providers/kubernetes/services/core/core_minimize_root_containers_admission/core_minimize_root_containers_admission.metadata.json index 1bd3d81be6..0f636ad335 100644 --- a/prowler/providers/kubernetes/services/core/core_minimize_root_containers_admission/core_minimize_root_containers_admission.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_minimize_root_containers_admission/core_minimize_root_containers_admission.metadata.json @@ -1,27 +1,33 @@ { "Provider": "kubernetes", "CheckID": "core_minimize_root_containers_admission", - "CheckTitle": "Minimize the admission of root containers", + "CheckTitle": "Pod does not run any container as the root user", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that Kubernetes clusters are configured to minimize the admission of containers running as the root user. Running containers as root increases the risk of container breakout and should be restricted.", - "Risk": "Allowing containers to run as root can lead to elevated risk of security breaches and container breakout.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "Description": "**Kubernetes Pods** are assessed for containers configured to run as the **root user**. The evaluation identifies containers whose security context sets `runAsUser: 0`.", + "Risk": "Containers running as **root (UID 0)** enable **privilege escalation** and **container breakout**. Attackers can modify workloads (**integrity**), read sensitive data on mounted volumes (**confidentiality**), and disrupt nodes or services via kernel/daemon abuse (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tutorials/security/cluster-level-pss/", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/", + "https://kyverno.io/policies/pod-security/restricted/require-run-as-nonroot/require-run-as-nonroot/", + "https://support.icompaas.com/support/solutions/articles/62000234712-minimize-the-admission-of-root-containers" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_5#kubernetes", - "Other": "", - "Terraform": "" + "CLI": "kubectl patch pod -n --type=strategic -p '{\"spec\":{\"containers\":[{\"name\":\"\",\"securityContext\":{\"runAsUser\":1000}}]}}'", + "NativeIaC": "", + "Other": "1. In your Kubernetes dashboard or kubectl editor, open the workload (Deployment/StatefulSet/DaemonSet) that created the failing Pod.\n2. Edit the YAML and set a non-root UID for the specific container:\n ```yaml\n spec:\n template:\n spec:\n containers:\n - name: \n securityContext:\n runAsUser: 1000 # Critical: non-zero UID\n ```\n3. Save and apply. Wait for the new Pod to start and verify the finding is resolved.", + "Terraform": "```hcl\nresource \"kubernetes_pod\" \"\" {\n metadata {\n name = \"\"\n }\n spec {\n container {\n name = \"\"\n image = \"\"\n security_context {\n run_as_user = 1000 # Critical: set a non-zero UID so the container is not root\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict the use of root containers through admission control policies.", - "Url": "https://kubernetes.io/docs/concepts/security/pod-security-standards/" + "Text": "Require non-root execution and enforce **least privilege**:\n- Set `runAsNonRoot: true` and a non-zero `runAsUser`\n- Use images with a defined non-root UID\n- Apply **Pod Security Standards - restricted** or policies to block UID `0`\n- Use `allowPrivilegeEscalation: false` and drop unnecessary capabilities", + "Url": "https://hub.prowler.com/check/core_minimize_root_containers_admission" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/core/core_no_secrets_envs/core_no_secrets_envs.metadata.json b/prowler/providers/kubernetes/services/core/core_no_secrets_envs/core_no_secrets_envs.metadata.json index 8b141525d3..ae80bcfa43 100644 --- a/prowler/providers/kubernetes/services/core/core_no_secrets_envs/core_no_secrets_envs.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_no_secrets_envs/core_no_secrets_envs.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "core_no_secrets_envs", - "CheckTitle": "Prefer using secrets as files over secrets as environment variables", + "CheckTitle": "Pod does not contain secret environment variables", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesSecrets", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "security", - "Description": "This check ensures that secrets in Kubernetes are used as files rather than environment variables. Using secrets as files is safer, as it reduces the risk of exposing sensitive data through application logs.", - "Risk": "Secrets exposed as environment variables can be inadvertently logged by applications, leading to potential security breaches.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets", + "Description": "**Kubernetes Pods** containers define environment variables sourced from **Secrets** via `secretKeyRef` instead of mounting them as files.", + "Risk": "Secrets in env vars weaken **confidentiality**:\n- Leak via logs, dumps, `/proc/*/environ`, debug UIs, and pod metadata\n- Propagate to child processes; rotation is hard\nAttackers can steal credentials for unauthorized access and **lateral movement**, risking data integrity and service availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-files-over-environment-variables", + "https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_33#kubernetes", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Open Kubernetes Dashboard and go to Workloads > Deployments (or StatefulSets/Pods)\n2. Select and click Edit > Edit YAML\n3. For each container, remove any env entries that use valueFrom.secretKeyRef\n4. If the app still needs the secret, add a volume that references your Secret and mount it into the container (e.g., mountPath /etc/secret)\n5. Save the changes", + "Terraform": "```hcl\n# Use a Secret as a volume (no secretKeyRef in env) to pass the check\nresource \"kubernetes_pod\" \"\" {\n metadata { name = \"\" }\n spec {\n container {\n name = \"\"\n image = \"busybox\"\n volume_mount {\n name = \"-secret\" # critical: mount Secret as files instead of env vars\n mount_path = \"/etc/secret\"\n read_only = true\n }\n }\n volume {\n name = \"-secret\"\n secret { secret_name = \"\" } # critical: reference the Secret via a volume\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Minimize the use of environment variable secrets and prefer mounting secrets as files for enhanced security.", - "Url": "https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-files-over-environment-variables" + "Text": "Use **Secrets as files** (read-only volumes) and load at runtime.\n- Apply **least privilege** RBAC to Secret access\n- Scope Secrets to required containers; avoid logging env\n- Prefer short-lived creds and regular rotation; set `immutable: true` when suitable\n- Layer **defense in depth** with network and runtime controls", + "Url": "https://hub.prowler.com/check/core_no_secrets_envs" } }, "Categories": [ - "trustboundaries" + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/core/core_seccomp_profile_docker_default/core_seccomp_profile_docker_default.metadata.json b/prowler/providers/kubernetes/services/core/core_seccomp_profile_docker_default/core_seccomp_profile_docker_default.metadata.json index 0da7c476dc..aeced98c20 100644 --- a/prowler/providers/kubernetes/services/core/core_seccomp_profile_docker_default/core_seccomp_profile_docker_default.metadata.json +++ b/prowler/providers/kubernetes/services/core/core_seccomp_profile_docker_default/core_seccomp_profile_docker_default.metadata.json @@ -1,27 +1,31 @@ { "Provider": "kubernetes", "CheckID": "core_seccomp_profile_docker_default", - "CheckTitle": "Ensure that the seccomp profile is set to docker/default in your pod definitions", + "CheckTitle": "Pod has the docker/default (RuntimeDefault) seccomp profile at pod level or for all containers", "CheckType": [], "ServiceName": "core", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesPod", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check verifies that the docker/default seccomp profile is enabled in pod definitions. Enabling seccomp profiles helps restrict the set of system calls applications can make, enhancing the security of workloads in the cluster.", - "Risk": "Not using or incorrectly configuring seccomp profiles can leave containers with broader permissions, increasing the risk of malicious actions.", - "RelatedUrl": "https://kubernetes.io/docs/tutorials/clusters/seccomp/", + "Description": "**Kubernetes Pods** and their containers specify the runtime default seccomp profile using `seccompProfile.type: RuntimeDefault` in the security context.\n\nThe evaluation looks for this setting at the Pod level or per container.", + "Risk": "Without **seccomp RuntimeDefault**, containers may run unconfined and invoke risky syscalls, expanding the kernel attack surface.\n- Container escape, privilege escalation (integrity)\n- Data access or exfiltration (confidentiality)\n- Node or workload disruption (availability)", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tutorials/clusters/seccomp/", + "https://docs.docker.com/engine/security/seccomp/" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/bc_k8s_30#kubernetes", - "Other": "", - "Terraform": "" + "CLI": "kubectl patch deployment --type=merge -p '{\"spec\":{\"template\":{\"spec\":{\"securityContext\":{\"seccompProfile\":{\"type\":\"RuntimeDefault\"}}}}}}'", + "NativeIaC": "", + "Other": "1. Open the manifest of your workload (Deployment/StatefulSet/Pod)\n2. Under the Pod spec, add:\n \n spec:\n securityContext:\n seccompProfile:\n type: RuntimeDefault \n \n - For controllers (e.g., Deployment), place this under spec.template.spec\n3. Apply the change: kubectl apply -f \n4. Wait for pods to restart and confirm the setting on new pods", + "Terraform": "```hcl\nresource \"kubernetes_pod_v1\" \"example\" {\n metadata {\n name = \"\"\n }\n spec {\n security_context {\n seccomp_profile {\n type = \"RuntimeDefault\" # Critical: enforces the runtime default (docker/default) seccomp profile at pod level\n }\n }\n container {\n name = \"app\"\n image = \"nginx\"\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Implement the docker/default seccomp profile in pod definitions for enhanced container security.", - "Url": "https://docs.docker.com/engine/security/seccomp/" + "Text": "Enforce **least privilege** for syscalls:\n- Set `seccompProfile.type: RuntimeDefault` on Pods/containers\n- Use tailored profiles for sensitive workloads\n- Avoid privileged or unconfined containers; drop unused capabilities\n- Combine with AppArmor/SELinux and policy guardrails to enforce and audit", + "Url": "https://hub.prowler.com/check/core_seccomp_profile_docker_default" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_authorization_mode/kubelet_authorization_mode.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_authorization_mode/kubelet_authorization_mode.metadata.json index cec6b9ebfd..ffec62db14 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_authorization_mode/kubelet_authorization_mode.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_authorization_mode/kubelet_authorization_mode.metadata.json @@ -1,31 +1,37 @@ { "Provider": "kubernetes", "CheckID": "kubelet_authorization_mode", - "CheckTitle": "Ensure that the kubelet --authorization-mode argument is not set to AlwaysAllow", + "CheckTitle": "Kubelet --authorization-mode is not set to AlwaysAllow", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesKubelet", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check ensures that kubelets are not set to use the 'AlwaysAllow' authorization mode, which would allow all authenticated requests without explicit authorization.", - "Risk": "Setting --authorization-mode to AlwaysAllow can lead to unauthorized access to kubelet services.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/", + "Description": "**Kubernetes Kubelet** authorization configuration is inspected to confirm the mode is not `AlwaysAllow`.\n\n*If authorization settings are absent, the effective mode requires manual verification.*", + "Risk": "With `AlwaysAllow`, any authenticated user (or anonymous if enabled) can call **kubelet APIs**. This enables reading logs and stats, running `exec`, or disrupting pods, leading to takeover, data exfiltration, and node abuse, degrading **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/", + "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/" + ], "Remediation": { "Code": { - "CLI": "--authorization-mode=Webhook", + "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. In your cluster admin shell, run: kubectl -n kube-system edit configmap kubelet-config-\n2. In the opened YAML, set the authorization mode to Webhook (add if missing):\n authorization:\n mode: Webhook\n3. Save and exit. Re-run the scan to confirm the finding is now PASS.", "Terraform": "" }, "Recommendation": { - "Text": "Ensure kubelet is configured with an authorization mode other than AlwaysAllow.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/#kubelet-authorization" + "Text": "Use kubelet authorization mode `Webhook` so decisions defer to **RBAC**. Apply **least privilege** on node subresources, disable anonymous access, and restrict network exposure of the kubelet endpoint. Employ **defense in depth** with TLS and audit to monitor and control access.", + "Url": "https://hub.prowler.com/check/kubelet_authorization_mode" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_client_ca_file_set/kubelet_client_ca_file_set.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_client_ca_file_set/kubelet_client_ca_file_set.metadata.json index e578131944..c914df8b92 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_client_ca_file_set/kubelet_client_ca_file_set.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_client_ca_file_set/kubelet_client_ca_file_set.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "kubelet_client_ca_file_set", - "CheckTitle": "Ensure that the kubelet --client-ca-file argument is set as appropriate", + "CheckTitle": "Kubelet has a client CA file configured for authentication", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesKubelet", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check verifies that the kubelet is configured with the --client-ca-file argument to enable authentication using certificates. This configuration is essential to secure the connections from the apiserver to the kubelet.", - "Risk": "If --client-ca-file is not set, the apiserver cannot authenticate the kubelet, potentially leading to man-in-the-middle attacks.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/", + "Description": "**Kubernetes Kubelet** is evaluated for X.509 client certificate authentication by checking if its config sets `authentication.x509.clientCAFile` to validate clients on the HTTPS endpoint.", + "Risk": "Without a **client CA**, the kubelet cannot verify client certificates, weakening authentication. With network access, attackers could impersonate trusted clients to read pod logs/stats or perform node/pod actions, impacting confidentiality, integrity, and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/" + ], "Remediation": { "Code": { - "CLI": "--client-ca-file=/path/to/ca-file", + "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. On each node, open the kubelet config file used by the --config flag (commonly /var/lib/kubelet/config.yaml).\n2. Add or update this setting to provide a client CA bundle path:\n ```yaml\n authentication:\n x509:\n clientCAFile: \n ```\n3. Save and restart kubelet: `sudo systemctl restart kubelet`", "Terraform": "" }, "Recommendation": { - "Text": "Configure Kubelet with a client CA file for secure authentication.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/#kubelet-authorization" + "Text": "Enforce **mutual TLS** to the kubelet by providing a trusted `clientCAFile`.\n- Disable anonymous access\n- Delegate authorization to the API server with least-privilege RBAC\n- Restrict network exposure to the kubelet\n- Rotate certificates and monitor access\n\n*Use defense-in-depth across authn and authz.*", + "Url": "https://hub.prowler.com/check/kubelet_client_ca_file_set" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_conf_file_ownership/kubelet_conf_file_ownership.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_conf_file_ownership/kubelet_conf_file_ownership.metadata.json index 8721368cfd..4384b4d7bd 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_conf_file_ownership/kubelet_conf_file_ownership.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_conf_file_ownership/kubelet_conf_file_ownership.metadata.json @@ -1,27 +1,31 @@ { "Provider": "kubernetes", "CheckID": "kubelet_conf_file_ownership", - "CheckTitle": "Ensure kubelet.conf file ownership is set to root:root", + "CheckTitle": "Node kubelet.conf file ownership is set to root:root", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesWorkerNode", + "ResourceType": "Node", "ResourceGroup": "container", - "Description": "Ensure that the kubelet.conf file, which is the kubeconfig file for the node, has its file ownership set to root:root. This check verifies the proper ownership settings to maintain the security and integrity of the node's configuration.", - "Risk": "Incorrect file ownership settings on kubelet.conf can lead to unauthorized access and potential security vulnerabilities.", - "RelatedUrl": "https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/kubelet-integration/", + "Description": "**Kubernetes Node kubeconfig** at `/etc/kubernetes/kubelet.conf` is evaluated for file ownership `root:root`. The check focuses on who owns the file that defines the kubelet's API client settings and certificates.", + "Risk": "Non-root ownership lets local users alter kubelet API credentials and endpoints, enabling **node impersonation**, unauthorized control of Pods, and **data exfiltration** via the kubelet. This threatens **integrity** (config tampering), **confidentiality** (secrets access), and **availability** (pod eviction or node disruption).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/kubelet-integration/", + "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/" + ], "Remediation": { "Code": { "CLI": "chown root:root /etc/kubernetes/kubelet.conf", "NativeIaC": "", - "Other": "", + "Other": "1. SSH into the affected node\n2. Run: sudo chown root:root /etc/kubernetes/kubelet.conf", "Terraform": "" }, "Recommendation": { - "Text": "Ensure kubelet.conf file ownership is correctly set to protect the node's configuration.", - "Url": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/" + "Text": "Set `/etc/kubernetes/kubelet.conf` ownership to `root:root` and use restrictive perms (e.g., `600`). Apply **least privilege** on node access, protect kubelet dirs, and enable **file integrity monitoring**. Use **defense in depth**: configuration management to enforce state and periodic audits to detect drift.", + "Url": "https://hub.prowler.com/check/kubelet_conf_file_ownership" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_conf_file_permissions/kubelet_conf_file_permissions.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_conf_file_permissions/kubelet_conf_file_permissions.metadata.json index 863c5babd3..cf8a88f57f 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_conf_file_permissions/kubelet_conf_file_permissions.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_conf_file_permissions/kubelet_conf_file_permissions.metadata.json @@ -1,27 +1,31 @@ { "Provider": "kubernetes", "CheckID": "kubelet_conf_file_permissions", - "CheckTitle": "Ensure kubelet.conf file permissions are set to 600 or more restrictive", + "CheckTitle": "Node kubelet.conf file permissions are set to 600 or more restrictive", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesWorkerNode", + "ResourceType": "Node", "ResourceGroup": "container", - "Description": "Ensure that the kubelet.conf file, which is the kubeconfig file for the node, has permissions set to 600 or more restrictive. This ensures the integrity and security of the node's configuration.", - "Risk": "Improper permissions on kubelet.conf can expose sensitive configuration data, potentially leading to cluster security compromises.", - "RelatedUrl": "https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/kubelet-integration/", + "Description": "**Kubernetes Kubelet kubeconfig** at `/etc/kubernetes/kubelet.conf` must have **owner-only** permissions (`0600` or stricter). The check evaluates the file mode to ensure it is not more permissive than `0600`.", + "Risk": "**Overly permissive `kubelet.conf`** exposes kubelet credentials, allowing local users or malware to act as the node.\n- Integrity: modify workloads or node state\n- Confidentiality: access secrets/metadata\n- Availability: disrupt scheduling or drain nodes", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/kubelet-integration/", + "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/" + ], "Remediation": { "Code": { - "CLI": "chmod 600 /etc/kubernetes/kubelet.conf", + "CLI": "sudo chmod 600 /etc/kubernetes/kubelet.conf", "NativeIaC": "", - "Other": "", + "Other": "1. SSH into the node running kubelet\n2. Set restrictive permissions:\n ```\n sudo chmod 600 /etc/kubernetes/kubelet.conf\n ```\n3. Verify it reads 600:\n ```\n stat -c \"%a %n\" /etc/kubernetes/kubelet.conf\n ```", "Terraform": "" }, "Recommendation": { - "Text": "Ensure kubelet.conf file permissions are correctly set to protect the node's configuration.", - "Url": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/" + "Text": "Apply **least privilege** to `/etc/kubernetes/kubelet.conf`:\n- Set permissions to `0600` or stricter\n- Restrict ownership to the kubelet user; no group/world access\n- Limit shell access and monitor file changes\n- Layer controls with **RBAC** and certificate/key rotation", + "Url": "https://hub.prowler.com/check/kubelet_conf_file_permissions" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_config_yaml_ownership/kubelet_config_yaml_ownership.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_config_yaml_ownership/kubelet_config_yaml_ownership.metadata.json index 3457833504..90f5ac9d23 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_config_yaml_ownership/kubelet_config_yaml_ownership.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_config_yaml_ownership/kubelet_config_yaml_ownership.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "kubelet_config_yaml_ownership", - "CheckTitle": "Validate kubelet config.yaml File Ownership", + "CheckTitle": "Node kubelet config.yaml file ownership is root:root", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesWorkerNode", + "ResourceType": "Node", "ResourceGroup": "container", - "Description": "Ensure that if the kubelet refers to a configuration file with the --config argument, that file is owned by root:root. The kubelet config file contains various critical parameters for the kubelet service on worker nodes, and its ownership should be strictly controlled.", - "Risk": "Improper file ownership on kubelet config.yaml can expose sensitive data or allow unauthorized modifications.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "Description": "**Kubernetes Kubelet** configuration file `config.yaml` (e.g., `/var/lib/kubelet/config.yaml`) is evaluated to confirm ownership by `root:root` when the kubelet uses a config file via `--config`.", + "Risk": "**Non-root ownership** of kubelet `config.yaml` enables local users or daemons to alter node-agent settings, affecting confidentiality, integrity, and availability. They could weaken authN/Z, enable insecure ports, or redirect certificate paths, leading to node takeover, lateral movement, data exfiltration, and workload disruption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000234742-if-the-kubelet-config-yaml-configuration-file-is-being-used-validate-file-ownership-is-set-to-root-r", + "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/" + ], "Remediation": { "Code": { "CLI": "chown root:root /var/lib/kubelet/config.yaml", "NativeIaC": "", - "Other": "", + "Other": "1. SSH into the Kubernetes node running the kubelet\n2. Set ownership to root:root:\n ```bash\n sudo chown root:root /var/lib/kubelet/config.yaml\n ```", "Terraform": "" }, "Recommendation": { - "Text": "Secure the kubelet configuration by enforcing strict file ownership.", - "Url": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/" + "Text": "Enforce `root:root` ownership with restrictive permissions on the kubelet config. Apply **least privilege** and **separation of duties** so only trusted admins/processes can write. Use centralized, immutable configuration, monitor with integrity/audit logs, and limit interactive access to nodes for **defense in depth**.", + "Url": "https://hub.prowler.com/check/kubelet_config_yaml_ownership" } }, "Categories": [ - "node-security" + "node-security", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_config_yaml_permissions/kubelet_config_yaml_permissions.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_config_yaml_permissions/kubelet_config_yaml_permissions.metadata.json index 8c3893df66..bafa7b6ce6 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_config_yaml_permissions/kubelet_config_yaml_permissions.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_config_yaml_permissions/kubelet_config_yaml_permissions.metadata.json @@ -1,27 +1,30 @@ { "Provider": "kubernetes", "CheckID": "kubelet_config_yaml_permissions", - "CheckTitle": "Validate kubelet config.yaml File Permissions", + "CheckTitle": "Kubelet config.yaml file permissions on the node are set to 600 or more restrictive", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesWorkerNode", + "ResourceType": "Node", "ResourceGroup": "container", - "Description": "Ensure that if the kubelet refers to a configuration file with the --config argument, that file has permissions of 600 or more restrictive. The kubelet config file contains various critical parameters for the kubelet service on worker nodes, and its permissions should be strictly controlled.", - "Risk": "Improper file permissions on kubelet config.yaml can expose sensitive data or allow unauthorized modifications.", - "RelatedUrl": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/", + "Description": "**Kubernetes Kubelet configuration file** (`/var/lib/kubelet/config.yaml`) is evaluated for **restrictive file permissions**. When kubelet uses `--config`, the file is expected to be owner-only readable/writable (`600`) or more restrictive.", + "Risk": "Overly permissive kubelet config permissions allow unauthorized reads or edits. Attackers could extract credentials, adjust auth settings, or change node behavior, leading to data exposure (C), configuration tampering (I), and potential service disruption (A).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/" + ], "Remediation": { "Code": { "CLI": "chmod 600 /var/lib/kubelet/config.yaml", "NativeIaC": "", - "Other": "", + "Other": "1. SSH into the affected node with sufficient privileges\n2. Set the file permission:\n ```bash\n sudo chmod 600 /var/lib/kubelet/config.yaml\n ```\n3. Verify:\n ```bash\n stat -c \"%a\" /var/lib/kubelet/config.yaml\n # should output: 600\n ```", "Terraform": "" }, "Recommendation": { - "Text": "Secure the kubelet configuration by enforcing strict file permissions.", - "Url": "https://kubernetes.io/docs/tasks/administer-cluster/kubelet-config-file/" + "Text": "Apply **least privilege** to the kubelet config:\n- Set mode `600` or stricter\n- Ensure trusted ownership; deny group/world access\n- Harden the parent directory\n- Enforce via config management and file integrity monitoring\n- Limit interactive access to worker nodes", + "Url": "https://hub.prowler.com/check/kubelet_config_yaml_permissions" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_disable_anonymous_auth/kubelet_disable_anonymous_auth.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_disable_anonymous_auth/kubelet_disable_anonymous_auth.metadata.json index 45d84a8b41..d7664b5a7f 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_disable_anonymous_auth/kubelet_disable_anonymous_auth.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_disable_anonymous_auth/kubelet_disable_anonymous_auth.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "kubelet_disable_anonymous_auth", - "CheckTitle": "Ensure that the --anonymous-auth argument is set to false", + "CheckTitle": "Kubelet anonymous authentication is disabled", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesKubelet", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check ensures that anonymous requests to the Kubelet server are disabled by setting the --anonymous-auth argument to false. Disabling anonymous requests enhances the security by ensuring that all requests are authenticated and authorized.", - "Risk": "Enabling anonymous requests can lead to unauthorized access to Kubelet APIs and potentially sensitive cluster data.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/#kubelet-authorization", + "Description": "**Kubernetes Kubelet** configuration for its HTTPS endpoint is evaluated to ensure **anonymous authentication** is disabled, requiring authenticated requests (`authentication.anonymous.enabled=false`).", + "Risk": "Allowing anonymous access to the kubelet exposes node and pod data and can permit privileged actions, impacting CIA: disclosure via logs/metrics (C), unauthorized container operations or exec (I), and pod or node disruption (A). This can enable lateral movement and broader cluster compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/" + ], "Remediation": { "Code": { - "CLI": "--anonymous-auth=false", + "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. SSH to each Kubernetes node with root privileges\n2. Edit /var/lib/kubelet/config.yaml and set the following:\n\n```yaml\n# Critical: disables anonymous requests to the kubelet\nauthentication:\n anonymous:\n enabled: false\n```\n\n3. Restart kubelet:\n\n```bash\nsudo systemctl restart kubelet\n```", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that anonymous requests to the Kubelet server are disabled for enhanced cluster security.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/" + "Text": "Disable anonymous auth and require **strong, authenticated clients** (mTLS or tokens). Delegate authorization to **RBAC** and apply **least privilege** for kubelet APIs. Limit network exposure to the kubelet endpoint and monitor access patterns as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/kubelet_disable_anonymous_auth" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_disable_read_only_port/kubelet_disable_read_only_port.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_disable_read_only_port/kubelet_disable_read_only_port.metadata.json index 296e409387..b7d0ee6707 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_disable_read_only_port/kubelet_disable_read_only_port.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_disable_read_only_port/kubelet_disable_read_only_port.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "kubelet_disable_read_only_port", - "CheckTitle": "Verify that the kubelet --read-only-port argument is set to 0", + "CheckTitle": "Kubelet read-only port is disabled (set to 0)", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesKubelet", + "Severity": "medium", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check ensures that the read-only port of the Kubelet is disabled by verifying that the --read-only-port argument is set to 0. Disabling the read-only port is crucial to prevent unauthenticated access to sensitive cluster data.", - "Risk": "If the read-only port is open, it could allow unauthenticated access to sensitive cluster information.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/", + "Description": "**Kubernetes Kubelet** configuration is inspected for the `readOnlyPort` setting and whether it is set to `0` to disable the unauthenticated HTTP endpoint.", + "Risk": "An open **kubelet read-only port** allows unauthenticated queries to node and pod metadata and metrics, causing **information disclosure**. Attackers can map workloads, discover endpoints, and prepare **lateral movement**, undermining **confidentiality** and enabling targeted exploitation of weak configurations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/" + ], "Remediation": { "Code": { - "CLI": "--read-only-port=0", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-read-only-port-argument-is-set-to-0", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. Edit the kubelet configuration ConfigMap: run `kubectl -n kube-system edit configmap/` (e.g., kubelet-config-1.xx)\n2. In the data entry that contains the KubeletConfiguration YAML, add or set this top-level line:\n ```\n readOnlyPort: 0\n ```\n3. Save and exit\n4. Restart kubelet on each node to apply:\n ```\n sudo systemctl restart kubelet\n ```", "Terraform": "" }, "Recommendation": { - "Text": "Disable the read-only port in the kubelet for enhanced cluster security.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options" + "Text": "Disable the unauthenticated endpoint by setting `readOnlyPort: 0`.\n\nApply **least privilege**: expose only the TLS-authenticated kubelet endpoint, enforce authorization, and restrict network access to kubelet with host firewalls or network policies. Monitor nodes for unexpected open ports.", + "Url": "https://hub.prowler.com/check/kubelet_disable_read_only_port" } }, "Categories": [ - "trustboundaries" + "cluster-security", + "node-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_event_record_qps/kubelet_event_record_qps.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_event_record_qps/kubelet_event_record_qps.metadata.json index 9ab878d143..17e1021a02 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_event_record_qps/kubelet_event_record_qps.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_event_record_qps/kubelet_event_record_qps.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "kubelet_event_record_qps", - "CheckTitle": "Ensure that the kubelet eventRecordQPS argument is set to an appropriate level", + "CheckTitle": "Kubelet eventRecordQPS is set to a positive value", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesKubelet", + "Severity": "high", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check ensures that the Kubelet is configured with an appropriate eventRecordQPS level. The eventRecordQPS parameter limits the rate at which events are gathered, ensuring important security events are not missed while preventing potential denial-of-service conditions.", - "Risk": "An inappropriate eventRecordQPS setting could lead to missing vital security events or DoS conditions.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/", + "Description": "**Kubernetes Kubelet** configuration defines **event rate limiting** via `eventRecordQPS`. The setting is evaluated for presence and a positive value, where `0` means unlimited event generation.", + "Risk": "Uncapped or mis-tuned event rates can overwhelm the API and etcd, reducing **availability**, or suppress key signals, harming the **integrity** of telemetry. Noisy pods or abuse can flood events; too-low caps drop diagnostics, hindering detection and response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/" + ], "Remediation": { "Code": { - "CLI": "--event-qps=", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-event-qps-argument-is-set-to-0-or-a-level-which-ensures-appropriate-event-capture", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each node running kubelet\n2. Edit /var/lib/kubelet/config.yaml and set: eventRecordQPS: 5 (any integer > 0)\n3. Restart kubelet: sudo systemctl restart kubelet", "Terraform": "" }, "Recommendation": { - "Text": "Configure kubelet with a balanced eventRecordQPS setting for effective event capture without causing DoS conditions.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options" + "Text": "Set `eventRecordQPS` to a positive, workload-appropriate rate and avoid `0`. Monitor event volumes and backpressure and tune periodically. Apply **defense in depth** by limiting noisy workloads and enforcing operational safeguards to prevent event storms.", + "Url": "https://hub.prowler.com/check/kubelet_event_record_qps" } }, "Categories": [ - "logging" + "resilience", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_manage_iptables/kubelet_manage_iptables.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_manage_iptables/kubelet_manage_iptables.metadata.json index 6303861202..d559103501 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_manage_iptables/kubelet_manage_iptables.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_manage_iptables/kubelet_manage_iptables.metadata.json @@ -1,31 +1,34 @@ { "Provider": "kubernetes", "CheckID": "kubelet_manage_iptables", - "CheckTitle": "Ensure that the kubelet --make-iptables-util-chains argument is set to true", + "CheckTitle": "Kubelet configuration has makeIPTablesUtilChains set to true", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesKubelet", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check ensures that the Kubelet is configured to manage iptables, which keeps the iptables configuration in sync with the dynamic pod network configuration. Allowing the Kubelet to manage iptables helps to avoid network communication issues between pods/containers.", - "Risk": "If kubelet does not manage iptables, manual configurations might conflict with dynamic pod networking, causing communication issues.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/", + "Description": "**Kubernetes Kubelet** configured with `makeIPTablesUtilChains` manages **iptables utility chains**, keeping node firewall primitives aligned with dynamic pod networking. The setting is expected to be present and enabled in kubelet configuration.", + "Risk": "Without kubelet-managed iptables chains, nodes can drift to inconsistent firewall states, enabling:\n- Traffic drops and service outages (availability)\n- Unintended inter-pod exposure (confidentiality)\n- Conflicting rules altering expected flows (integrity)", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/" + ], "Remediation": { "Code": { - "CLI": "--make-iptables-util-chains=true", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-make-iptables-util-chains-argument-is-set-to-true", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each node running kubelet\n2. Edit the kubelet config file:\n - sudo vi /var/lib/kubelet/config.yaml\n3. In the KubeletConfiguration, set:\n - makeIPTablesUtilChains: true\n4. Save and restart kubelet:\n - sudo systemctl restart kubelet", "Terraform": "" }, "Recommendation": { - "Text": "Enable kubelet management of iptables for consistent network configuration.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options" + "Text": "Enable kubelet management of **iptables utility chains** by setting `makeIPTablesUtilChains=true`.\n\nPrefer **automation over manual iptables edits** to prevent drift. Combine with **network policies**, least privilege, and **defense in depth** to control east-west traffic. *If your CNI replaces iptables, document the exception*.", + "Url": "https://hub.prowler.com/check/kubelet_manage_iptables" } }, "Categories": [ - "internet-exposed" + "node-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_rotate_certificates/kubelet_rotate_certificates.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_rotate_certificates/kubelet_rotate_certificates.metadata.json index 2fdd29f2e8..5f51f21d99 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_rotate_certificates/kubelet_rotate_certificates.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_rotate_certificates/kubelet_rotate_certificates.metadata.json @@ -1,32 +1,36 @@ { "Provider": "kubernetes", "CheckID": "kubelet_rotate_certificates", - "CheckTitle": "Ensure that the kubelet client certificate rotation is enabled", + "CheckTitle": "Kubelet client certificate rotation is enabled", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesKubelet", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check ensures that the kubelet client certificate rotation is enabled, allowing for automated periodic rotation of credentials, thereby addressing availability concerns in the security triad. This is crucial for avoiding downtime due to expired certificates.", - "Risk": "Not enabling kubelet client certificate rotation may lead to service interruptions due to expired certificates, compromising the availability of the node.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/", + "Description": "**Kubernetes Kubelet** configuration is inspected for client TLS credential rotation. The finding determines whether `rotateCertificates` is enabled so kubelets automatically renew the client certificates they use to authenticate to the API server.", + "Risk": "Without rotation, kubelet client certs can expire, breaking authentication to the API server and making nodes NotReady, disrupting scheduling and operations (**availability**). Long-lived certs also widen exposure if keys leak, risking unauthorized access (**integrity**, **confidentiality**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/", + "https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-certs/" + ], "Remediation": { "Code": { - "CLI": "--rotate-certificates=true", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-rotate-certificates-argument-is-not-set-to-false", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each Kubernetes node\n2. Open /var/lib/kubelet/config.yaml\n3. Add or set the line:\n ```yaml\n rotateCertificates: true\n ```\n If using kubelet flags instead of a config file, remove --rotate-certificates=false (or set --rotate-certificates=true) from the kubelet service options\n4. Restart kubelet:\n ```\n sudo systemctl restart kubelet\n ```", "Terraform": "" }, "Recommendation": { - "Text": "Enable kubelet client certificate rotation for automated renewal of credentials.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/#certificate-rotation" + "Text": "Enable **kubelet client certificate rotation** by setting `rotateCertificates: true`. Apply controlled CSR approval and monitor certificate health to ensure timely renewals. Prefer short-lived, automatically rotated credentials over static keys, aligning with **least privilege** and **defense in depth**. *If using an external CA*, implement equivalent automated rotation.", + "Url": "https://hub.prowler.com/check/kubelet_rotate_certificates" } }, "Categories": [ "encryption", - "internet-exposed" + "node-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_service_file_ownership_root/kubelet_service_file_ownership_root.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_service_file_ownership_root/kubelet_service_file_ownership_root.metadata.json index 08639977b8..4e3bb0b872 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_service_file_ownership_root/kubelet_service_file_ownership_root.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_service_file_ownership_root/kubelet_service_file_ownership_root.metadata.json @@ -1,27 +1,31 @@ { "Provider": "kubernetes", "CheckID": "kubelet_service_file_ownership_root", - "CheckTitle": "Ensure that the kubelet service file ownership is set to root:root", + "CheckTitle": "Kubelet service file on the node is owned by root:root", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesWorkerNode", + "ResourceType": "Node", "ResourceGroup": "container", - "Description": "This check ensures that the kubelet service file on each Node is owned by root. Proper file ownership is critical for the security and integrity of the kubelet service configuration.", - "Risk": "Incorrect ownership settings can lead to unauthorized modifications, potentially compromising the security and functionality of the kubelet service.", - "RelatedUrl": "https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/kubelet-integration/", + "Description": "**Kubernetes Kubelet service configuration** on each node is expected to be owned by **root**. This assessment inspects the systemd drop-in at `/etc/systemd/system/kubelet.service.d/kubeadm.conf` and expects ownership `root:root`, ensuring only privileged users can modify kubelet startup settings.", + "Risk": "Non-root ownership enables unauthorized changes to kubelet startup flags, degrading configuration **integrity** and service **availability**. Attackers could disable auth, expose ports, weaken TLS, or load rogue configs-leading to node compromise, credential theft (**confidentiality**), lateral movement, and cluster disruption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/kubelet-integration/", + "https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-config/" + ], "Remediation": { "Code": { - "CLI": "chown root:root /etc/systemd/system/kubelet.service.d/kubeadm.conf", + "CLI": "sudo chown root:root /etc/systemd/system/kubelet.service.d/kubeadm.conf", "NativeIaC": "", - "Other": "", + "Other": "1. SSH into the node with sudo privileges\n2. Run: sudo chown root:root /etc/systemd/system/kubelet.service.d/kubeadm.conf", "Terraform": "" }, "Recommendation": { - "Text": "Set the kubelet service file ownership to root:root to maintain its integrity.", - "Url": "https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-config/" + "Text": "Apply **least privilege** to node config: ensure the kubelet service file is `root:root` and writable only by root. Use **configuration management** to enforce permissions and detect drift, enable **file integrity monitoring**, harden the OS, and restrict privileged node access with **separation of duties**.", + "Url": "https://hub.prowler.com/check/kubelet_service_file_ownership_root" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_service_file_permissions/kubelet_service_file_permissions.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_service_file_permissions/kubelet_service_file_permissions.metadata.json index e7510edc02..3d33492af1 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_service_file_permissions/kubelet_service_file_permissions.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_service_file_permissions/kubelet_service_file_permissions.metadata.json @@ -1,27 +1,32 @@ { "Provider": "kubernetes", "CheckID": "kubelet_service_file_permissions", - "CheckTitle": "Ensure that the kubelet service file permissions are set to 600 or more restrictive", + "CheckTitle": "Node kubelet service file permissions are set to 600 or more restrictive", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesNode", + "ResourceType": "Node", "ResourceGroup": "container", - "Description": "This check ensures that the kubelet service file on worker nodes has permissions set to 600 or more restrictive, limiting the file's write access to only system administrators. This measure is crucial to maintain the integrity and security of the kubelet service configuration.", - "Risk": "Improper file permissions on the kubelet service file could lead to unauthorized modifications, compromising node security and stability.", - "RelatedUrl": "https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-config/", + "Description": "**Kubernetes Kubelet service file** on worker nodes is assessed for restrictive permissions of `600` or tighter. The evaluation reviews the kubelet systemd drop-in configuration to confirm only the owner has read/write access and no broader permissions are granted.", + "Risk": "Excess permissions let local users alter kubelet startup options, undermining **integrity** and **availability**. Attackers could enable insecure ports, weaken auth/TLS, or inject flags, leading to node compromise, pod tampering, and **lateral movement** that threatens cluster **confidentiality**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://support.icompaas.com/support/solutions/articles/62000234693-ensure-that-the-kubelet-service-file-permissions-are-set-to-600-or-more-restrictive", + "https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/#44-joining-your-nodes", + "https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-config/" + ], "Remediation": { "Code": { "CLI": "chmod 600 /etc/systemd/system/kubelet.service.d/kubeadm.conf", "NativeIaC": "", - "Other": "", + "Other": "1. SSH to the affected Kubernetes node as a privileged user\n2. Set the kubelet service file permissions: `chmod 600 /etc/systemd/system/kubelet.service.d/kubeadm.conf`\n3. Repeat on each node where this file exists", "Terraform": "" }, "Recommendation": { - "Text": "Ensure the kubelet service file is securely configured with restrictive permissions.", - "Url": "https://kubernetes.io/docs/setup/independent/create-cluster-kubeadm/#44-joining-your-nodes" + "Text": "Apply **least privilege**: set the kubelet service file to `600` with root control to block group/other access. Enforce via **configuration management**, restrict administrative shell access to nodes, and monitor with **file integrity** alerts. Use **defense in depth** by maintaining strong kubelet auth and TLS settings.", + "Url": "https://hub.prowler.com/check/kubelet_service_file_permissions" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_streaming_connection_timeout/kubelet_streaming_connection_timeout.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_streaming_connection_timeout/kubelet_streaming_connection_timeout.metadata.json index 68727dced0..6f26c3e515 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_streaming_connection_timeout/kubelet_streaming_connection_timeout.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_streaming_connection_timeout/kubelet_streaming_connection_timeout.metadata.json @@ -1,27 +1,30 @@ { "Provider": "kubernetes", "CheckID": "kubelet_streaming_connection_timeout", - "CheckTitle": "Ensure that the kubelet --streaming-connection-idle-timeout argument is not set to 0", + "CheckTitle": "Kubelet streaming connection idle timeout is not set to 0", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesKubelet", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check ensures that the Kubelet is configured with a non-zero timeout for streaming connections. Setting a non-zero timeout helps protect against Denial-of-Service attacks and resource exhaustion due to idle connections.", - "Risk": "A zero timeout on streaming connections can lead to Denial-of-Service attacks and resource exhaustion.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/", + "Description": "**Kubernetes Kubelet** streaming sessions use a **non-zero idle timeout** via `streamingConnectionIdleTimeout` for `exec`, `logs`, and `port-forward` connections.\n\nAssesses whether this setting exists and is not `0`.", + "Risk": "With `0` (no timeout), idle `exec/logs/port-forward` streams can persist, consuming sockets, file descriptors, and memory. This enables resource exhaustion and **DoS**, degrading node **availability** and potentially disrupting workload operations and administrative access.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/" + ], "Remediation": { "Code": { - "CLI": "--streaming-connection-idle-timeout=", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-streaming-connection-idle-timeout-argument-is-not-set-to-0", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to each Kubernetes node.\n2. If kubelet uses a config file, set a non-zero timeout:\n - Find the path: `systemctl cat kubelet | grep -- --config=`\n - Edit that file and add/update:\n ```yaml\n streamingConnectionIdleTimeout: \"5m\"\n ```\n3. If no config file is used, add the flag to the kubelet service drop-in (e.g., `/etc/systemd/system/kubelet.service.d/10-kubeadm.conf`) so the kubelet starts with:\n ```\n --streaming-connection-idle-timeout=5m\n ```\n4. Apply the change: `sudo systemctl daemon-reload && sudo systemctl restart kubelet`", "Terraform": "" }, "Recommendation": { - "Text": "Configure a non-zero timeout for streaming connections in kubelet to enhance node security.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options" + "Text": "Set a bounded, non-zero `streamingConnectionIdleTimeout` aligned to operational needs; avoid `0`.\n\nApply consistently across nodes, prefer fail-safe defaults, and monitor for unusually long streaming sessions. This supports **defense in depth** and preserves node **availability**.", + "Url": "https://hub.prowler.com/check/kubelet_streaming_connection_timeout" } }, "Categories": [ diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_strong_ciphers_only/kubelet_strong_ciphers_only.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_strong_ciphers_only/kubelet_strong_ciphers_only.metadata.json index 05fb0efcb9..f9df12b7e4 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_strong_ciphers_only/kubelet_strong_ciphers_only.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_strong_ciphers_only/kubelet_strong_ciphers_only.metadata.json @@ -1,32 +1,36 @@ { "Provider": "kubernetes", "CheckID": "kubelet_strong_ciphers_only", - "CheckTitle": "Ensure that the Kubelet only makes use of Strong Cryptographic Ciphers", + "CheckTitle": "Kubelet uses only strong TLS cipher suites", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "KubernetesKubelet", + "Severity": "medium", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check verifies that the kubelet is configured to use only strong cryptographic ciphers. Ensuring the use of strong ciphers is essential to minimize the risk of vulnerabilities and enhance the security of TLS connections to the kubelet.", - "Risk": "Using weak ciphers can expose the kubelet to cryptographic attacks, compromising the security of data in transit.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/", + "Description": "**Kubernetes Kubelet HTTPS** configuration is assessed for use of **strong TLS cipher suites**. The presence of `tlsCipherSuites` is checked and its values are validated against an approved, modern allowlist (e.g., ECDHE with GCM/CHACHA20).", + "Risk": "Weak or unspecified ciphers enable downgrade and cryptographic attacks on kubelet traffic. Adversaries could read logs/exec streams, hijack sessions, or tamper with requests, undermining **confidentiality** and **integrity** and facilitating lateral movement across nodes.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/", + "https://support.icompaas.com/support/solutions/articles/62000234732-ensure-that-the-kubelet-only-makes-use-of-strong-cryptographic-ciphers" + ], "Remediation": { "Code": { - "CLI": "--tls-cipher-suites=TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,...", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-kubelet-only-makes-use-of-strong-cryptographic-ciphers", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. Identify the kubelet config ConfigMap name:\n - Run: kubectl -n kube-system get configmap | grep kubelet-config\n2. Edit that ConfigMap:\n - Run: kubectl -n kube-system edit configmap >\n - In the data.kubelet YAML (KubeletConfiguration), add the following and save:\n \n tlsCipherSuites:\n - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256\n - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256\n - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305\n - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384\n - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305\n - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384\n - TLS_RSA_WITH_AES_256_GCM_SHA384\n - TLS_RSA_WITH_AES_128_GCM_SHA256\n3. Apply on each node (required for kubelets using local config):\n - SSH to the node, edit /var/lib/kubelet/config.yaml to include the same tlsCipherSuites list\n - Restart kubelet: sudo systemctl restart kubelet", "Terraform": "" }, "Recommendation": { - "Text": "Restrict the kubelet to only use strong cryptographic ciphers for enhanced security.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/#options" + "Text": "Restrict `tlsCipherSuites` to a minimal set of modern suites (ECDHE with GCM or CHACHA20) and prefer `TLS1.2+`/`TLS1.3`. Remove deprecated CBC/RC4/3DES suites. Apply **defense in depth**: limit network access to the kubelet, rotate certificates, and review cipher policy regularly for deprecations.", + "Url": "https://hub.prowler.com/check/kubelet_strong_ciphers_only" } }, "Categories": [ "encryption", - "internet-exposed" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/kubelet/kubelet_tls_cert_and_key/kubelet_tls_cert_and_key.metadata.json b/prowler/providers/kubernetes/services/kubelet/kubelet_tls_cert_and_key/kubelet_tls_cert_and_key.metadata.json index 17a3c7607e..eb85d0eb67 100644 --- a/prowler/providers/kubernetes/services/kubelet/kubelet_tls_cert_and_key/kubelet_tls_cert_and_key.metadata.json +++ b/prowler/providers/kubernetes/services/kubelet/kubelet_tls_cert_and_key/kubelet_tls_cert_and_key.metadata.json @@ -1,32 +1,35 @@ { "Provider": "kubernetes", "CheckID": "kubelet_tls_cert_and_key", - "CheckTitle": "Ensure that the kubelet TLS certificate and private key are set appropriately", + "CheckTitle": "Kubelet has TLS certificate and private key files configured", "CheckType": [], "ServiceName": "kubelet", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "KubernetesKubelet", + "ResourceType": "ConfigMap", "ResourceGroup": "container", - "Description": "This check ensures that each Kubelet is configured with a TLS certificate and private key for secure connections. These settings are crucial for preventing man-in-the-middle attacks and ensuring secure communication between the apiserver and kubelets.", - "Risk": "Not setting the kubelet's TLS certificate and private key can expose the node to security vulnerabilities and interception of sensitive data.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/", + "Description": "**Kubernetes Kubelet** configuration includes a TLS serving certificate and private key defined by `tlsCertFile` and `tlsPrivateKeyFile` to secure its HTTPS endpoint.", + "Risk": "Without a verifiable TLS serving cert/key, kubelet traffic may fall back to self-signed or skipped verification, enabling **MITM** and endpoint **spoofing**. Attackers could read logs or pod data, run **exec/attach**, and alter node interactions, compromising **confidentiality** and **integrity**, and risking **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/" + ], "Remediation": { "Code": { - "CLI": "--tls-cert-file= --tls-private-key-file=", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-tls-cert-file-and-tls-private-key-file-arguments-are-set-as-appropriate-for-kubelet", - "Other": "", - "Terraform": "" + "CLI": "kubectl -n kube-system patch configmap --type merge -p '{\"data\":{\"kubelet\":\"tlsCertFile: /var/lib/kubelet/pki/kubelet.crt\\ntlsPrivateKeyFile: /var/lib/kubelet/pki/kubelet.key\\n\"}}'", + "NativeIaC": "", + "Other": "1. In your cluster, open the kubelet config ConfigMap: kubectl -n kube-system edit configmap kubelet-config-\n2. Under the KubeletConfiguration root, add:\n - tlsCertFile: /var/lib/kubelet/pki/kubelet.crt\n - tlsPrivateKeyFile: /var/lib/kubelet/pki/kubelet.key\n3. Save and exit.\n4. On each node, restart kubelet to apply: sudo systemctl restart kubelet\n5. Verify the ConfigMap now contains both fields and the check passes.", + "Terraform": "```hcl\nresource \"kubernetes_config_map\" \"\" {\n metadata {\n name = \"\"\n namespace = \"kube-system\"\n }\n data = {\n kubelet = <<-YAML\n tlsCertFile: /var/lib/kubelet/pki/kubelet.crt # FIX: adds kubelet TLS cert path\n tlsPrivateKeyFile: /var/lib/kubelet/pki/kubelet.key # FIX: adds kubelet TLS key path\n YAML\n }\n}\n```" }, "Recommendation": { - "Text": "Configure each kubelet with its own TLS certificate and private key for secure connections.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/kubelet-tls-bootstrapping/#client-and-serving-certificates" + "Text": "Provision a **CA-signed, node-unique** serving certificate and private key for each kubelet and set `tlsCertFile` and `tlsPrivateKeyFile`. Prefer automated issuance and rotation. Ensure clients validate the certificate and limit kubelet API exposure with network controls and RBAC, applying **least privilege** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/kubelet_tls_cert_and_key" } }, "Categories": [ "encryption", - "internet-exposed" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_cluster_admin_usage/rbac_cluster_admin_usage.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_cluster_admin_usage/rbac_cluster_admin_usage.metadata.json index 11f681f7c5..b101179167 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_cluster_admin_usage/rbac_cluster_admin_usage.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_cluster_admin_usage/rbac_cluster_admin_usage.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "rbac_cluster_admin_usage", - "CheckTitle": "Ensure that the cluster-admin role is only used where required", + "CheckTitle": "Cluster role binding does not grant the cluster-admin role", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", + "Severity": "critical", "ResourceType": "ClusterRoleBinding", "ResourceGroup": "IAM", - "Description": "This check ensures that the 'cluster-admin' role, which provides wide-ranging powers, is used only where necessary. The 'cluster-admin' role grants super-user access to perform any action on any resource, including all namespaces. It should be applied cautiously to avoid excessive privileges.", - "Risk": "Inappropriate use of the 'cluster-admin' role can lead to excessive privileges, increasing the risk of malicious actions and potentially impacting the cluster's security posture.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles", + "Description": "RBAC ClusterRoleBindings that bind to the `cluster-admin` ClusterRole are identified, showing where subjects receive super-user permissions across all namespaces.", + "Risk": "**Excessive `cluster-admin` grants** give full API control, risking:\n- Secret exfiltration (confidentiality)\n- RBAC tampering (integrity)\n- Destructive actions causing outages (availability)\n\nA compromised subject can escalate, persist via new bindings, and laterally impact all namespaces.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#user-facing-roles", + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#clusterrolebinding-example" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "kubectl delete clusterrolebinding ", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. List ClusterRoleBindings that use cluster-admin:\n ```\n kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name==\"cluster-admin\") | .metadata.name'\n ```\n2. For each name returned, delete the binding:\n ```\n kubectl delete clusterrolebinding \n ```\n3. If access is still required, recreate with least privilege (example uses view):\n ```\n kubectl create clusterrolebinding --clusterrole=view --user=\n ```", + "Terraform": "```hcl\nresource \"kubernetes_cluster_role_binding\" \"\" {\n metadata { name = \"\" }\n role_ref {\n api_group = \"rbac.authorization.k8s.io\"\n kind = \"ClusterRole\"\n name = \"view\" # Critical: replace any \"cluster-admin\" binding with a limited role\n }\n subject {\n kind = \"User\"\n name = \"\"\n }\n}\n```" }, "Recommendation": { - "Text": "Audit and assess the use of 'cluster-admin' role in all ClusterRoleBindings. Ensure it is assigned only to subjects that require such extensive privileges. Consider using more restrictive roles wherever possible.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#clusterrolebinding-example" + "Text": "Apply **least privilege**: replace `cluster-admin` with narrowly scoped Roles/ClusterRoles and bind per namespace. Reserve super-user access for break-glass, time-bound with approval and audit. Enforce **separation of duties**, review RBAC regularly, and monitor role/binding changes for **defense in depth**.", + "Url": "https://hub.prowler.com/check/rbac_cluster_admin_usage" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.metadata.json index 31bed6351a..13bd0b8f7a 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "rbac_minimize_csr_approval_access", - "CheckTitle": "Minimize access to the approval sub-resource of certificatesigningrequests objects", + "CheckTitle": "User or group lacks update and patch access to the certificatesigningrequests/approval sub-resource", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "CertificateSigningRequestApproval", + "ResourceType": "ClusterRoleBinding", "ResourceGroup": "security", - "Description": "This check ensures that access to the approval sub-resource of certificate signing request (CSR) objects is restricted. Access to update the approval sub-resource can lead to privilege escalation, allowing creation of new high-privileged user accounts in the cluster.", - "Risk": "Unauthorized access to update the approval sub-resource of CSR objects can lead to significant security vulnerabilities, including unauthorized user creation and privilege escalation.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#csrs-and-certificate-issuing", + "Description": "**RBAC assignments** that grant `update` or `patch` on the `certificatesigningrequests/approval` subresource to **users or groups** via cluster-wide roles and bindings.\n\nThis highlights principals allowed to approve CSRs based on permissions defined in referenced ClusterRoles.", + "Risk": "Excess CSR approval rights enable **privilege escalation**. A malicious user can approve CSRs to mint client certificates for arbitrary identities, gaining unauthorized API access, impersonating system components, and enabling **data exfiltration** and tampering, harming **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#csrs-and-certificate-issuing" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-clusterroles-that-grant-permissions-to-approve-certificatesigningrequests-are-minimized", - "Other": "", - "Terraform": "" + "CLI": "kubectl delete clusterrolebinding ", + "NativeIaC": "", + "Other": "1. Open the Kubernetes Dashboard\n2. Go to Access control (RBAC) > Cluster Role Bindings\n3. Find the binding that assigns a User or Group to a role permitting certificatesigningrequests/approval\n4. Click the binding, then Delete to remove it\n5. If you must keep the binding: go to Access control (RBAC) > Cluster Roles, open the referenced role, click Edit (YAML), remove the rule that has resource \"certificatesigningrequests/approval\" with verbs [\"update\", \"patch\"], and save", + "Terraform": "```hcl\nresource \"kubernetes_cluster_role\" \"safe\" {\n metadata { name = \"\" }\n # No rules included -> ensures no update/patch on certificatesigningrequests/approval\n}\n\nresource \"kubernetes_cluster_role_binding\" \"safe_bind\" {\n metadata { name = \"\" }\n role_ref {\n api_group = \"rbac.authorization.k8s.io\"\n kind = \"ClusterRole\"\n name = kubernetes_cluster_role.safe.metadata[0].name # Critical: binds subject to a role without CSR approval rights\n }\n subject {\n kind = \"User\" # Use \"Group\" if binding a group\n name = \"\"\n api_group = \"rbac.authorization.k8s.io\"\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict access to the approval sub-resource of CSR objects in the cluster.", - "Url": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#csrs-and-certificate-issuing" + "Text": "Apply **least privilege**: allow CSR approval only to a small, trusted approver role.\n- Enforce **separation of duties** between CSR creation and approval\n- Prefer automated approver controllers over manual grants\n- Regularly review RBAC and remove broad ClusterRoleBindings\n- Use **defense in depth** with auditing and short-lived credentials", + "Url": "https://hub.prowler.com/check/rbac_minimize_csr_approval_access" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.metadata.json index cd2e66a691..c19b768c88 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "rbac_minimize_node_proxy_subresource_access", - "CheckTitle": "Minimize access to the proxy sub-resource of nodes", + "CheckTitle": "User or group has no get, list, or watch permissions on the nodes/proxy sub-resource", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "NodeProxySubResource", + "ResourceType": "ClusterRoleBinding", "ResourceGroup": "container", - "Description": "This check ensures that access to the proxy sub-resource of node objects is restricted. Access to this sub-resource can grant privileges to use the Kubelet API directly, bypassing Kubernetes API controls like audit logging and admission control, potentially leading to privilege escalation.", - "Risk": "Unauthorized access to the proxy sub-resource of node objects can lead to significant security vulnerabilities, including privilege escalation.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#access-to-proxy-subresource-of-nodes", + "Description": "**RBAC permissions** to the `nodes/proxy` subresource are analyzed. Any user or group granted `get`, `list`, or `watch` via cluster-wide bindings is reported.", + "Risk": "Access to `nodes/proxy` exposes the **Kubelet API** through the apiserver proxy, which can bypass **audit** and **admission** controls. An adversary could execute commands in pods, read sensitive data, and pivot across nodes, impacting confidentiality and integrity; broad access also risks node-wide service disruption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#access-to-proxy-subresource-of-nodes" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "kubectl delete clusterrolebinding ", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Identify the ClusterRoleBinding that grants the user/group access: in your RBAC list, locate the binding referencing a ClusterRole that includes resources: nodes/proxy with verbs get, list, or watch\n2. Edit the ClusterRole: run `kubectl edit clusterrole `\n3. In `rules`, remove any entry where `resources` includes `nodes/proxy` and `verbs` includes `get`, `list`, or `watch`; save and exit\n4. If you cannot modify the role (shared role), delete the specific ClusterRoleBinding that attaches it to the user/group: `kubectl delete clusterrolebinding `", + "Terraform": "```hcl\n# Remove nodes/proxy access by defining the ClusterRole without that permission\nresource \"kubernetes_cluster_role\" \"\" {\n metadata {\n name = \"\"\n }\n # Critical fix: no rule granting get/list/watch on \"nodes/proxy\" so subjects won't have that access\n}\n```" }, "Recommendation": { - "Text": "Restrict access to the proxy sub-resource of node objects in the cluster.", - "Url": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#access-to-proxy-subresource-of-nodes" + "Text": "Apply **least privilege**: avoid granting any verbs on `nodes/proxy` to users or groups. If access is unavoidable, limit it to trusted admins, scope narrowly, make it time-bound, and enforce **separation of duties**. Prefer namespace roles over cluster-wide bindings and review RBAC regularly as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/rbac_minimize_node_proxy_subresource_access" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pod_creation_access/rbac_minimize_pod_creation_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pod_creation_access/rbac_minimize_pod_creation_access.metadata.json index 1db10b8b4e..a6c5b69c0e 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pod_creation_access/rbac_minimize_pod_creation_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pod_creation_access/rbac_minimize_pod_creation_access.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "rbac_minimize_pod_creation_access", - "CheckTitle": "Minimize access to create pods", + "CheckTitle": "Role or ClusterRole does not grant create permission on pods", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Pod", + "ResourceType": "ClusterRole", "ResourceGroup": "container", - "Description": "This check ensures that the ability to create pods in a Kubernetes cluster is restricted to a minimal group of users. Limiting pod creation access mitigates the risk of privilege escalation and exposure of sensitive data.", - "Risk": "Unrestricted access to create pods can lead to potential security risks and privilege escalation within the cluster.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "Description": "**Kubernetes RBAC** Roles and ClusterRoles that grant the `create` verb on `pods` are identified.\n\nRules are examined to find permissions allowing pod creation at namespace or cluster scope.", + "Risk": "Overly broad pod-creation rights allow adversaries to run **malicious pods**, mount service account tokens, or request privileged configs, enabling lateral movement. This endangers **confidentiality** (secrets), **integrity** (unauthorized changes), and **availability** (resource abuse, disruption).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "https://kubegrade.com/kubernetes-access-control/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "kubectl patch clusterrole -p '{\"rules\":[{\"apiGroups\":[\"\"],\"resources\":[\"pods\"],\"verbs\":[\"get\",\"list\",\"watch\"]}]}'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Identify the failing RBAC object (Role or ClusterRole) that allows pods create\n2. For a ClusterRole: run `kubectl edit clusterrole `\n3. For a namespaced Role: run `kubectl edit role -n `\n4. In `rules`, find entries with `resources: [\"pods\"]` and remove `create` from `verbs` (keep only read verbs like get/list/watch)\n5. Save and exit; verify with `kubectl get` that the change applied", + "Terraform": "```hcl\nresource \"kubernetes_cluster_role_v1\" \"\" {\n metadata {\n name = \"\"\n }\n rule {\n api_groups = [\"\"]\n resources = [\"pods\"]\n verbs = [\"get\", \"list\", \"watch\"] # critical: omit \"create\" to prevent pod creation and pass the check\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict pod creation access to minimize security risks.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#role-and-clusterrole" + "Text": "Apply **least privilege**: limit `pods` `create` to narrowly scoped service accounts and namespaces. Use **separation of duties** and avoid wildcards. Enforce controls with **Pod Security Admission** and policy engines (OPA/Kyverno) to block risky specs. Review RBAC regularly and remove unused grants.", + "Url": "https://hub.prowler.com/check/rbac_minimize_pod_creation_access" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.metadata.json index 6f5d3fda21..472f136621 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "rbac_minimize_pv_creation_access", - "CheckTitle": "Minimize access to create persistent volumes", + "CheckTitle": "User or group does not have permission to create PersistentVolumes", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "PersistentVolume", + "ResourceType": "ClusterRoleBinding", "ResourceGroup": "storage", - "Description": "This check ensures that the ability to create persistent volumes in Kubernetes is restricted to authorized users only. Limiting this capability helps prevent privilege escalation scenarios through the creation of hostPath volumes.", - "Risk": "Excessive permissions to create persistent volumes can lead to unauthorized access to sensitive host files, overriding the restrictions imposed by Pod Security Admission policies.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#persistent-volume-creation", + "Description": "**Kubernetes RBAC** mapping of **users or groups** with the `create` verb on `persistentvolumes` through ClusterRoleBindings.\n\nShows which principals can provision PersistentVolumes cluster-wide.", + "Risk": "With `create` on `persistentvolumes`, a principal can define **hostPath PVs** and mount them via claims, exposing node files and bypassing pod safeguards.\n\nResults include secret disclosure and filesystem tampering (**confidentiality**/**integrity**), and potential node compromise affecting **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.radsecurity.ai/blog/what-is-kubernetes-rbac", + "https://kubernetes.io/docs/concepts/security/rbac-good-practices/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "kubectl delete clusterrolebinding ", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Open the Kubernetes Dashboard (or your cluster RBAC UI)\n2. Navigate to ClusterRoles and select the role referenced by the failing ClusterRoleBinding\n3. Edit the role and locate the rule for resources: persistentvolumes\n4. Remove the verb \"create\" from that rule's verbs list\n5. Save the change to apply updated permissions", + "Terraform": "```hcl\nresource \"kubernetes_cluster_role\" \"\" {\n metadata { name = \"\" }\n\n rule {\n api_groups = [\"\"]\n resources = [\"persistentvolumes\"]\n verbs = [\"get\", \"list\", \"watch\"]\n # Critical: exclude \"create\" on persistentvolumes to prevent PV creation\n # This removes the permission that caused the FAIL finding.\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict access to create persistent volumes in the cluster.", - "Url": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#persistent-volume-creation" + "Text": "Enforce **least privilege**: permit `create` on `persistentvolumes` only for trusted storage controllers and select administrators.\n\nAdopt **dynamic provisioning**, maintain **separation of duties**, and use **defense in depth** (Pod Security and admission policies) to prevent unsafe volumes like `hostPath`.", + "Url": "https://hub.prowler.com/check/rbac_minimize_pv_creation_access" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_secret_access/rbac_minimize_secret_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_secret_access/rbac_minimize_secret_access.metadata.json index bcbd5ab1f3..7f47ad33e6 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_secret_access/rbac_minimize_secret_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_secret_access/rbac_minimize_secret_access.metadata.json @@ -1,32 +1,37 @@ { "Provider": "kubernetes", "CheckID": "rbac_minimize_secret_access", - "CheckTitle": "Minimize access to secrets", + "CheckTitle": "Role or ClusterRole does not grant get, list, or watch access to Kubernetes Secrets", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Secrets", + "ResourceType": "ClusterRole", "ResourceGroup": "security", - "Description": "This check ensures that access to secrets in the Kubernetes API is restricted to the smallest possible group of users. Minimizing access to secrets helps in reducing the risk of privilege escalation and potential unauthorized access to sensitive data.", - "Risk": "Inappropriate access to secrets can lead to escalation of privileges and unauthorized access to cluster resources or external resources managed through the secrets.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/configuration/secret/", + "Description": "RBAC Roles and ClusterRoles granting read permissions to **Kubernetes Secrets** are identified. The evaluation looks for rules that allow `get`, `list`, or `watch` on `secrets`, either namespace-scoped or cluster-wide.", + "Risk": "Excessive Secret read access compromises confidentiality of tokens, keys, and credentials. Attackers can harvest service account tokens to impersonate workloads, pivot across namespaces, and modify resources, threatening integrity and availability via lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "https://kubernetes.io/docs/concepts/configuration/secret/" + ], "Remediation": { "Code": { "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/no-serviceaccountnode-should-be-able-to-read-all-secrets", - "Other": "", - "Terraform": "" + "NativeIaC": "", + "Other": "1. Identify the offending Role or ClusterRole name\n2. Edit it: run either `kubectl edit clusterrole ` or `kubectl edit role `\n3. In `rules`, remove any entry where `resources` contains `secrets` with verbs `get`, `list`, or `watch` (or delete those verbs/resources from the rule)\n4. Save and exit; the change applies immediately", + "Terraform": "```hcl\n# Remove secret read permissions by ensuring no rule includes the \"secrets\" resource\nresource \"kubernetes_cluster_role\" \"\" {\n metadata {\n name = \"\"\n }\n rule {\n api_groups = [\"\"]\n resources = [\"pods\"] # Critical: do NOT include \"secrets\" to avoid get/list/watch on Secrets\n verbs = [\"get\"]\n }\n}\n```" }, "Recommendation": { - "Text": "Restrict access to Kubernetes secrets to the smallest possible set of users.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/" + "Text": "Apply **least privilege**: avoid granting `get`, `list`, or `watch` on `secrets` except to narrowly scoped subjects. Constrain by namespace and `resourceNames` where feasible, use dedicated service accounts, avoid wildcards, and enforce **separation of duties** with policy and reviews.", + "Url": "https://hub.prowler.com/check/rbac_minimize_secret_access" } }, "Categories": [ - "trustboundaries", - "encryption" + "identity-access", + "secrets", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.metadata.json index 5483467eab..ff4933688c 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "rbac_minimize_service_account_token_creation", - "CheckTitle": "Minimize access to the service account token creation", + "CheckTitle": "User or group does not have permission to create service account tokens", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "ServiceAccountToken", + "ResourceType": "ClusterRoleBinding", "ResourceGroup": "IAM", - "Description": "This check ensures that access to create new service account tokens is restricted within the Kubernetes cluster. Unrestricted token creation can lead to privilege escalation and persistent unauthorized access to the cluster.", - "Risk": "Granting excessive permissions for service account token creation can lead to abuse and compromise of cluster security.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#token-request", + "Description": "Cluster-wide RBAC identifies users or groups granted `create` on `serviceaccounts/token` via **ClusterRoles/ClusterRoleBindings**.\n\nHighlights principals allowed to mint **service account tokens** through the TokenRequest subresource.", + "Risk": "Ability to mint **service account tokens** lets users assume those accounts' API rights, bypassing intended boundaries.\n\nThis enables privilege escalation, persistent access, and lateral movement, threatening confidentiality and integrity of cluster resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#token-request" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "kubectl delete clusterrolebinding ", "NativeIaC": "", - "Other": "", + "Other": "1. Identify the ClusterRoleBinding that grants a User/Group the role with create on serviceaccounts/token\n2. Edit the referenced ClusterRole and remove any rule that allows create on resource \"serviceaccounts/token\":\n - kubectl edit clusterrole \n - In .rules[], delete entries with resources: [\"serviceaccounts/token\"] and verb: [\"create\"]\n3. If the permission is only needed for other subjects, alternatively remove the specific User/Group from the ClusterRoleBinding:\n - kubectl edit clusterrolebinding \n - Delete the matching subject under .subjects and save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict access to service account token creation in the cluster.", - "Url": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#token-request" + "Text": "Enforce **least privilege**: avoid granting `create` on `serviceaccounts/token` to human users or broad groups. Restrict to trusted controllers and scope narrowly with namespaced **Role**/**RoleBinding**.\n\nApply **separation of duties**, periodic RBAC reviews, and **defense in depth** to limit token issuance.", + "Url": "https://hub.prowler.com/check/rbac_minimize_service_account_token_creation" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.metadata.json index 334e28983f..86418422ea 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.metadata.json @@ -1,31 +1,35 @@ { "Provider": "kubernetes", "CheckID": "rbac_minimize_webhook_config_access", - "CheckTitle": "Minimize access to webhook configuration objects", + "CheckTitle": "User or group does not have create, update, or delete permissions on webhook configurations", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "WebhookConfiguration", + "ResourceType": "ClusterRoleBinding", "ResourceGroup": "container", - "Description": "This check ensures that access to webhook configuration objects (validatingwebhookconfigurations and mutatingwebhookconfigurations) is restricted. Unauthorized access or modification of these objects can lead to privilege escalation or disruption of cluster operations.", - "Risk": "Inadequately restricted access to webhook configurations can result in unauthorized control over webhooks, potentially allowing privilege escalation or interference with cluster functionality.", - "RelatedUrl": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#control-admission-webhooks", + "Description": "User or group RBAC assignments that grant privileges to `create`, `update`, or `delete` `validatingwebhookconfigurations` and `mutatingwebhookconfigurations` are identified.\n\nFocus is on permissions that allow modifying **admission webhook** configuration objects.", + "Risk": "Excess rights over **admission webhooks** allow reading or mutating all admitted API objects, undermining **confidentiality** and **integrity**.\n\nAn attacker could inject privileged mutations, bypass policy, or block admissions, leading to **privilege escalation** and cluster-wide **denial of service**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#control-admission-webhooks" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-clusterroles-that-grant-control-over-validating-or-mutating-admission-webhook-configurations-are-minimized", - "Other": "", + "CLI": "kubectl delete clusterrolebinding ", + "NativeIaC": "", + "Other": "1. Identify the ClusterRoleBinding that assigns a User or Group to a ClusterRole with create, update, or delete on validatingwebhookconfigurations or mutatingwebhookconfigurations (e.g., kubectl get clusterrolebindings).\n2. Delete that ClusterRoleBinding, or edit the bound ClusterRole to remove any rules granting those verbs on these resources.\n3. Save/apply the change; no restart is required.", "Terraform": "" }, "Recommendation": { - "Text": "Restrict access to webhook configuration objects in the cluster.", - "Url": "https://kubernetes.io/docs/concepts/security/rbac-good-practices/#control-admission-webhooks" + "Text": "- Enforce **least privilege**; limit webhook config `create`, `update`, `delete` to a small, trusted admin group.\n- Avoid wildcard resources/verbs and use **separation of duties** with change approval.\n- Monitor changes via **audit logging** to provide **defense in depth**.", + "Url": "https://hub.prowler.com/check/rbac_minimize_webhook_config_access" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/rbac_minimize_wildcard_use_roles.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/rbac_minimize_wildcard_use_roles.metadata.json index faae4af9ec..39af35a5b7 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/rbac_minimize_wildcard_use_roles.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/rbac_minimize_wildcard_use_roles.metadata.json @@ -1,31 +1,36 @@ { "Provider": "kubernetes", "CheckID": "rbac_minimize_wildcard_use_roles", - "CheckTitle": "Minimize wildcard use in Roles and ClusterRoles", + "CheckTitle": "Role or ClusterRole does not use wildcard resources or verbs", "CheckType": [], "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Role/ClusterRole", + "ResourceType": "ClusterRole", "ResourceGroup": "IAM", - "Description": "This check ensures that Roles and ClusterRoles in Kubernetes minimize the use of wildcards. Restricting wildcards enhances security by enforcing the principle of least privilege, ensuring users have only the access required for their role.", - "Risk": "Use of wildcards can lead to excessive rights being granted, potentially allowing users to access or modify resources beyond their scope of responsibility.", - "RelatedUrl": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "Description": "**Kubernetes RBAC Roles/ClusterRoles** are evaluated for wildcard use in rule `resources` or `verbs`. The presence of `*` means all resources or all actions are granted. This finding highlights roles whose rules include such wildcards.", + "Risk": "Using `*` broadens access beyond intent:\n- Confidentiality: unrestricted reads of sensitive data\n- Integrity/Availability: create/update/delete across resources\n- Privilege escalation and lateral movement\n- Future drift: new APIs/verbs inherit access automatically", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-minimized-wildcard-use-in-roles-and-clusterroles", - "Other": "", - "Terraform": "" + "CLI": "kubectl patch clusterrole --type=merge -p '{\"rules\":[{\"apiGroups\":[\"\"],\"resources\":[\"pods\"],\"verbs\":[\"get\"]}]}'", + "NativeIaC": "", + "Other": "1. Run: kubectl edit clusterrole \n2. In rules, replace any resources: [\"*\"] with only the needed resources (e.g., [\"pods\"]).\n3. Replace any verbs: [\"*\"] with only required verbs (e.g., [\"get\"]).\n4. Save and exit. Repeat for any Role or ClusterRole still using wildcards.", + "Terraform": "```hcl\nresource \"kubernetes_cluster_role_v1\" \"\" {\n metadata { name = \"\" }\n\n rule {\n api_groups = [\"\"]\n resources = [\"pods\"] # critical: replace wildcard resources with specific resource\n verbs = [\"get\"] # critical: replace wildcard verbs with specific verb\n }\n}\n```" }, "Recommendation": { - "Text": "Replace wildcards in roles and clusterroles with specific permissions.", - "Url": "https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources" + "Text": "Apply **least privilege**: replace `*` with explicit `resources`, `verbs`, and `resourceNames`. Split read/write duties, scope Roles to namespaces, use ClusterRoles only when needed, and bind to specific subjects. Periodically review roles to prevent privilege creep as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/rbac_minimize_wildcard_use_roles" } }, "Categories": [ - "trustboundaries" + "identity-access", + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/kubernetes/services/scheduler/scheduler_bind_address/scheduler_bind_address.metadata.json b/prowler/providers/kubernetes/services/scheduler/scheduler_bind_address/scheduler_bind_address.metadata.json index 918e793062..5e8d4a08dc 100644 --- a/prowler/providers/kubernetes/services/scheduler/scheduler_bind_address/scheduler_bind_address.metadata.json +++ b/prowler/providers/kubernetes/services/scheduler/scheduler_bind_address/scheduler_bind_address.metadata.json @@ -1,30 +1,34 @@ { "Provider": "kubernetes", "CheckID": "scheduler_bind_address", - "CheckTitle": "Ensure that the --bind-address argument is set to 127.0.0.1 for the Scheduler", + "CheckTitle": "Scheduler pod has --bind-address set to 127.0.0.1", "CheckType": [], "ServiceName": "scheduler", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "KubernetesScheduler", + "Severity": "high", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "This check ensures that the Kubernetes Scheduler is bound to the loopback address (127.0.0.1) to minimize the cluster's attack surface. Binding to the loopback address prevents unauthorized network access to the Scheduler's health and metrics information.", - "Risk": "Binding the Scheduler to a non-loopback address exposes sensitive health and metrics information without authentication or encryption.", - "RelatedUrl": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-scheduler/", + "Description": "**Kubernetes scheduler** is configured with `--bind-address=127.0.0.1` so its health and metrics endpoints listen only on localhost.\n\nThe evaluation inspects scheduler pod commands for this bind address.", + "Risk": "Exposing scheduler endpoints on non-loopback addresses can:\n- leak cluster state and scheduling metrics (**confidentiality**)\n- aid recon that enables privilege escalation (**integrity**)\n- allow health endpoint abuse for DoS (**availability**)", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-scheduler/" + ], "Remediation": { "Code": { - "CLI": "--bind-address=127.0.0.1", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-bind-address-argument-is-set-to-127001-1", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control plane node\n2. Open the static pod manifest: sudo vi /etc/kubernetes/manifests/kube-scheduler.yaml\n3. In spec.containers[0].command (or args) for kube-scheduler, add or set this exact flag: --bind-address=127.0.0.1\n4. Save the file; the kubelet will automatically restart the scheduler with the new setting", "Terraform": "" }, "Recommendation": { - "Text": "Bind the Scheduler to the loopback address for enhanced security.", - "Url": "https://kubernetes.io/docs/reference/command-line-tools-reference/kube-scheduler/" + "Text": "Bind the scheduler to localhost with `--bind-address=127.0.0.1` and disable insecure serving (`--port=0`). Use the secure port with TLS, restrict access via private networks or network policies, and limit metrics exposure. Apply **least privilege** and **defense in depth**, and monitor access.", + "Url": "https://hub.prowler.com/check/scheduler_bind_address" } }, "Categories": [ + "cluster-security", "internet-exposed" ], "DependsOn": [], diff --git a/prowler/providers/kubernetes/services/scheduler/scheduler_profiling/scheduler_profiling.metadata.json b/prowler/providers/kubernetes/services/scheduler/scheduler_profiling/scheduler_profiling.metadata.json index 256409e996..1a86e9b069 100644 --- a/prowler/providers/kubernetes/services/scheduler/scheduler_profiling/scheduler_profiling.metadata.json +++ b/prowler/providers/kubernetes/services/scheduler/scheduler_profiling/scheduler_profiling.metadata.json @@ -1,31 +1,34 @@ { "Provider": "kubernetes", "CheckID": "scheduler_profiling", - "CheckTitle": "Ensure that the --profiling argument is set to false", + "CheckTitle": "Scheduler pod has profiling disabled", "CheckType": [], "ServiceName": "scheduler", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "KubernetesScheduler", + "ResourceType": "Pod", "ResourceGroup": "container", - "Description": "Disable profiling in the Kubernetes Scheduler unless it is needed for troubleshooting. Profiling can reveal detailed system and application performance data, which might be exploited if exposed. Turning off profiling reduces the potential attack surface and performance overhead.", - "Risk": "While profiling is useful for identifying performance issues, it generates detailed data that could potentially expose sensitive information about the system and its performance characteristics.", - "RelatedUrl": "https://github.com/kubernetes/community/blob/master/contributors/devel/profiling.md", + "Description": "**Kubernetes Scheduler** profiling configuration, specifically whether scheduler containers run with `--profiling=false` to keep the profiling API disabled.", + "Risk": "With **profiling enabled**, the pprof endpoints can expose **runtime internals** (stack traces, memory, goroutines), aiding reconnaissance and credential discovery, harming **confidentiality**.\n\nExtra CPU/heap usage can be abused for **DoS**, impacting **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/admin/kube-scheduler/" + ], "Remediation": { "Code": { - "CLI": "--profiling=false", - "NativeIaC": "https://docs.prowler.com/checks/kubernetes/kubernetes-policy-index/ensure-that-the-profiling-argument-is-set-to-false-2", - "Other": "", + "CLI": "", + "NativeIaC": "", + "Other": "1. SSH to the control-plane node\n2. Edit the scheduler manifest: `sudo vi /etc/kubernetes/manifests/kube-scheduler.yaml`\n3. In `spec.containers[].command`, add this flag (or change existing to false):\n ```\n --profiling=false\n ```\n4. Save the file; kubelet will automatically restart the scheduler with profiling disabled", "Terraform": "" }, "Recommendation": { - "Text": "To minimize exposure to performance data and potential vulnerabilities, ensure the --profiling argument in the Kubernetes Scheduler is set to false.", - "Url": "https://kubernetes.io/docs/admin/kube-scheduler/" + "Text": "Disable by default: set `--profiling=false` on the Scheduler.\n\nIf profiling is required, enable it only temporarily, restrict access with **network policies**, bind to loopback, and log/monitor usage. Apply **least privilege** and **defense in depth** to limit exposure.", + "Url": "https://hub.prowler.com/check/scheduler_profiling" } }, "Categories": [ - "trustboundaries" + "cluster-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/m365/services/admincenter/admincenter_external_calendar_sharing_disabled/admincenter_external_calendar_sharing_disabled.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_external_calendar_sharing_disabled/admincenter_external_calendar_sharing_disabled.metadata.json index 1756b1acd2..c5ff3c2125 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_external_calendar_sharing_disabled/admincenter_external_calendar_sharing_disabled.metadata.json +++ b/prowler/providers/m365/services/admincenter/admincenter_external_calendar_sharing_disabled/admincenter_external_calendar_sharing_disabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "admincenter_external_calendar_sharing_disabled", - "CheckTitle": "Ensure external sharing of calendars is disabled", + "CheckTitle": "External calendar sharing is disabled at the organization level", "CheckType": [], "ServiceName": "admincenter", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Sharing Policy", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Restrict the ability for users to share their calendars externally in Microsoft 365. This prevents users from sending calendar sharing links to external recipients, reducing information exposure.", - "Risk": "Allowing calendar sharing outside the organization can help attackers build knowledge of personnel availability, relationships, and activity patterns, aiding social engineering or targeted attacks.", - "RelatedUrl": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/share-calendars-with-external-users?view=o365-worldwide", + "Description": "**Microsoft 365 calendar sharing** is evaluated at the organization level to determine if sharing with external recipients is disabled, including blocking anonymous access and links sent outside the tenant.", + "Risk": "Allowing **external calendar sharing** exposes meeting metadata (subjects, locations, attendees, patterns), weakening confidentiality. Adversaries can profile staff, craft convincing spear-phish or fake invites, time fraud attempts, and stage meeting hijacks, increasing **BEC** and social engineering success.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/share-calendars-with-external-users?view=o365-worldwide", + "https://m365scripts.com/microsoft365/how-to-stop-users-from-sharing-their-own-calendars/", + "https://learn.microsoft.com/en-my/answers/questions/1165808/external-calendar-sharing-for-single-user-in-exo" + ], "Remediation": { "Code": { - "CLI": "Set-SharingPolicy -Identity \"Default Sharing Policy\" -Enabled $False", + "CLI": "Set-SharingPolicy -Identity \"Default Sharing Policy\" -Enabled $false", "NativeIaC": "", - "Other": "1. Navigate to https://admin.microsoft.com. 2. Click Settings > Org settings. 3. Select Calendar in the Services section. 4. Uncheck 'Let your users share their calendars with people outside of your organization who have Office 365 or Exchange'. 5. Click Save.", + "Other": "1. Go to https://admin.microsoft.com and sign in\n2. Navigate to Settings > Org settings > Services > Calendar\n3. Under External sharing, uncheck \"Let your users share their calendars with people outside of your organization who have Microsoft 365 or Exchange\"\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable external calendar sharing by setting the Default Sharing Policy to disabled.", - "Url": "https://learn.microsoft.com/en-us/microsoft-365/admin/manage/share-calendars-with-external-users?view=o365-worldwide" + "Text": "Apply **least privilege**: disable **external calendar sharing** tenant-wide. If business-needed, allow only approved domains, require authenticated recipients (no anonymous), and limit details to `Free/Busy (time only)`. Review sharing policies regularly under **zero trust** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/admincenter_external_calendar_sharing_disabled" } }, "Categories": [ + "internet-exposed", + "trust-boundaries", "e5" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.metadata.json index c46e21a037..821ddb4273 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.metadata.json +++ b/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "admincenter_groups_not_public_visibility", - "CheckTitle": "Ensure that only organizationally managed/approved public groups exist", + "CheckTitle": "Microsoft 365 group has Private visibility", "CheckType": [], "ServiceName": "admincenter", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Active teams & groups", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that only organizationally managed and approved public groups exist to prevent unauthorized access to sensitive group resources like SharePoint, Teams, or other shared assets.", - "Risk": "Unmanaged public groups can allow unauthorized access to organizational resources, posing a risk of data leakage or misuse through easily guessable SharePoint URLs or self-adding to groups via the Azure portal.", - "RelatedUrl": "https://learn.microsoft.com/en-us/microsoft-365/admin/create-groups/manage-groups?view=o365-worldwide", + "Description": "**Microsoft 365 groups** are assessed for their visibility setting.\n\nThe finding highlights groups configured as `Public`; groups set to `Private` align with the intended privacy posture.", + "Risk": "**Public visibility** lets any authenticated tenant user discover and self-join, accessing group files, conversations, SharePoint sites, and calendars.\n\nThis weakens **confidentiality** and can enable unauthorized changes that affect **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/create-groups/manage-groups?view=o365-worldwide", + "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/microsoft-365-groups-governance" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "Set-UnifiedGroup -Identity -AccessType Private", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Click to expand Teams & groups select 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.", - "Terraform": "" + "Other": "1. Sign in to https://admin.microsoft.com\n2. Go to Teams & groups > Active teams & groups\n3. Select the group with visibility Public\n4. Open Settings\n5. Under Privacy, select Private\n6. Click Save", + "Terraform": "```hcl\nresource \"azuread_group\" \"\" {\n display_name = \"\"\n mail_enabled = true\n security_enabled = false\n types = [\"Unified\"]\n visibility = \"Private\" # Critical: sets the group visibility to Private to pass the check\n}\n```" }, "Recommendation": { - "Text": "Review and adjust the privacy settings of Microsoft 365 Groups to ensure only organizationally managed and approved public groups exist.", - "Url": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/microsoft-365-groups-governance" + "Text": "Set groups to `Private` by default and allow `Public` only for clearly non-sensitive communities.\n\nApply **least privilege**: restrict who can create or change visibility, require owner approval for membership, review access regularly, and use **sensitivity labels** to reinforce classification.", + "Url": "https://hub.prowler.com/check/admincenter_groups_not_public_visibility" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/admincenter/admincenter_organization_customer_lockbox_enabled/admincenter_organization_customer_lockbox_enabled.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_organization_customer_lockbox_enabled/admincenter_organization_customer_lockbox_enabled.metadata.json index 78a9102daf..04370ddc66 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_organization_customer_lockbox_enabled/admincenter_organization_customer_lockbox_enabled.metadata.json +++ b/prowler/providers/m365/services/admincenter/admincenter_organization_customer_lockbox_enabled/admincenter_organization_customer_lockbox_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "admincenter_organization_customer_lockbox_enabled", - "CheckTitle": "Ensure that customer lockbox is enabled for the organization", + "CheckTitle": "Customer Lockbox is enabled at the organization level", "CheckType": [], "ServiceName": "admincenter", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Organization Configuration", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Customer Lockbox ensures that Microsoft support engineers cannot access content in your tenant to perform a service operation without explicit approval. This feature provides an additional layer of control and transparency over data access requests.", - "Risk": "If Customer Lockbox is not enabled, Microsoft support personnel can access your organization's data for troubleshooting without explicit approval, potentially increasing the risk of unauthorized access or data exfiltration.", - "RelatedUrl": "https://learn.microsoft.com/en-us/azure/security/fundamentals/customer-lockbox-overview", + "Description": "**Microsoft 365 organization** setting indicates whether **Customer Lockbox** is enabled to require explicit approval for Microsoft support access to customer content", + "Risk": "Without **Customer Lockbox**, Microsoft engineers may access tenant content during support without your approval, undermining **confidentiality** and **accountability**. This increases risks of targeted data exposure, insider misuse, and weakens auditability of access during troubleshooting.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.gitbit.org/course/ms-500/learn/locking-down-your-microsoft-365-tenant-from-microsoft-engineers-fldnualgc", + "https://learn.microsoft.com/en-us/azure/security/fundamentals/customer-lockbox-overview", + "https://learn.microsoft.com/en-us/microsoft-365/compliance/customer-lockbox-requests?redirectSourcePath=%2flv-lv%2farticle%2fOffice-365-klientu-lockbox-piepras%25C4%25ABjumu-36f9cdd1-e64c-421b-a7e4-4a54d16440a2&view=o365-worldwide", + "https://learnthecontent.com/exam/microsoft-365/ms-700-managing-microsoft-teams/s/enable-customer-lockbox-for-data-security" + ], "Remediation": { "Code": { - "CLI": "Set-OrganizationConfig -CustomerLockBoxEnabled $true", + "CLI": "Set-OrganizationConfig -CustomerLockboxEnabled $true", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Click Settings > Org settings. 3. Select the Security & privacy tab. 4. Click Customer lockbox. 5. Check the box 'Require approval for all data access requests'. 6. Click Save.", + "Other": "1. Go to https://admin.microsoft.com and sign in\n2. Navigate to Settings > Org settings\n3. Select Security & privacy\n4. Click Customer Lockbox\n5. Check \"Require approval for all data access requests\"\n6. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable the Customer Lockbox feature to ensure explicit approval is required before Microsoft engineers can access your data during support operations.", - "Url": "https://learn.microsoft.com/en-us/azure/security/fundamentals/customer-lockbox-overview" + "Text": "Enable **Customer Lockbox** to enforce tenant approval for data access (`Require approval for all data access requests`).\n- Limit approvers per **least privilege**\n- Establish an on-call review workflow\n- Audit requests and engineer actions\n- Layer with **defense in depth** (MFA, RBAC, logging) to strengthen control", + "Url": "https://hub.prowler.com/check/admincenter_organization_customer_lockbox_enabled" } }, "Categories": [ + "identity-access", "e5" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/admincenter/admincenter_settings_password_never_expire/admincenter_settings_password_never_expire.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_settings_password_never_expire/admincenter_settings_password_never_expire.metadata.json index a844c80b00..e79fdee041 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_settings_password_never_expire/admincenter_settings_password_never_expire.metadata.json +++ b/prowler/providers/m365/services/admincenter/admincenter_settings_password_never_expire/admincenter_settings_password_never_expire.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "admincenter_settings_password_never_expire", - "CheckTitle": "Ensure the 'Password expiration policy' is set to 'Set passwords to never expire (recommended)'", + "CheckTitle": "Tenant password policy is set to never expire", "CheckType": [], "ServiceName": "admincenter", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Security & privacy settings", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "This control ensures that the password expiration policy is set to 'Set passwords to never expire (recommended)'. This aligns with modern recommendations to enhance security by avoiding arbitrary password changes and focusing on supplementary controls like MFA.", - "Risk": "Arbitrary password expiration policies can lead to weaker passwords due to frequent changes. Users may adopt insecure habits such as using simple, memorable passwords.", - "RelatedUrl": "https://www.cisecurity.org/insights/white-papers/cis-password-policy-guide", + "Description": "**Microsoft 365 tenant password policy** is configured so user passwords **do not expire** (`never expire`), meaning no time-based rotation is enforced across accounts.", + "Risk": "Forced password expiration degrades security: users adopt predictable patterns and reuse credentials, reducing **confidentiality** and **integrity**. It boosts success of **password spraying** and **credential stuffing**, encourages insecure storage and helpdesk resets, and can disrupt service accounts, impacting **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/admin/misc/password-policy-recommendations?view=o365-worldwide" + ], "Remediation": { "Code": { - "CLI": "Set-MsolUser -UserPrincipalName -PasswordNeverExpires $true", + "CLI": "Set-MsolPasswordPolicy -DomainName -ValidityPeriod 2147483647", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Click to expand Settings select Org Settings. 3. Click on Security & privacy. 4. Check the Set passwords to never expire (recommended) box. 5. Click Save.", + "Other": "1. Sign in to the Microsoft 365 admin center: https://admin.microsoft.com\n2. Go to Settings > Org settings > Security & privacy\n3. Open Password expiration policy and check Set passwords to never expire\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable the 'Never Expire Passwords' option in Microsoft 365 Admin Center.", - "Url": "https://learn.microsoft.com/en-us/microsoft-365/admin/misc/password-policy-recommendations?view=o365-worldwide" + "Text": "Set passwords to `never expire` and enforce layered controls: **MFA** with risk-based challenges, banned-password checks, and strong length. Apply **least privilege** and monitor sign-ins. Rotate credentials only after compromise or risk, and favor managed non-human identities for automation instead of time-based password cycling.", + "Url": "https://hub.prowler.com/check/admincenter_settings_password_never_expire" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint.metadata.json index 44654f0fb2..7d1e782451 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint.metadata.json +++ b/prowler/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "admincenter_users_admins_reduced_license_footprint", - "CheckTitle": "Ensure administrative accounts use licenses with a reduced application footprint", + "CheckTitle": "Administrative user has no license or an allowed license (AAD_PREMIUM or AAD_PREMIUM_P2)", "CheckType": [], "ServiceName": "admincenter", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Active users", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Administrative accounts must use licenses with a reduced application footprint, such as Microsoft Entra ID P1 or P2, or avoid licenses entirely when possible. This minimizes the attack surface associated with privileged identities.", - "Risk": "Licensing administrative accounts with applications like email or collaborative tools increases their exposure to social engineering attacks and malicious content, putting privileged accounts at risk.", - "RelatedUrl": "https://learn.microsoft.com/en-us/microsoft-365/enterprise/protect-your-global-administrator-accounts?view=o365-worldwide", + "Description": "Privileged users in Microsoft 365 are evaluated for a **reduced license footprint**: only `AAD_PREMIUM` (P1) or `AAD_PREMIUM_P2` (P2), or no license. Assignments that include productivity app suites indicate excess application entitlements on administrative accounts.", + "Risk": "Productivity licenses on **privileged identities** create mailboxes and collaboration surfaces that widen attack paths. Phishing, malicious content, or rogue OAuth apps can steal credentials/tokens, enabling tenant-wide role misuse, data exfiltration, and configuration tampering-compromising **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/enterprise/protect-your-global-administrator-accounts?view=o365-worldwide", + "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/add-users?view=o365-worldwide" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "Set-MgUserLicense -UserId -AddLicenses @() -RemoveLicenses @(Get-MgUserLicenseDetail -UserId | Select-Object -ExpandProperty SkuId)", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 admin center https://admin.microsoft.com. 2. Click to expand Users select 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.", + "Other": "1. Sign in to https://admin.microsoft.com\n2. Go to Users > Active users\n3. Select the affected administrative user\n4. Open Licenses and apps\n5. To pass the check, do ONE of the following:\n - Unassign all licenses (make the user unlicensed); or\n - Assign only Microsoft Entra ID P1 or P2 and remove all other licenses\n6. Click Save changes", "Terraform": "" }, "Recommendation": { - "Text": "Assign Microsoft Entra ID P1 or P2 licenses to administrative accounts to participate in essential security services without enabling access to vulnerable applications.", - "Url": "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/add-users?view=o365-worldwide" + "Text": "Maintain **dedicated admin accounts** with a **least-privilege, reduced-license** model: assign only Microsoft Entra P1/P2 features needed for identity security, or keep them unlicensed for apps. Separate admin from daily-use accounts, enforce **MFA** and just-in-time elevation, and use **privileged access workstations** for administration.", + "Url": "https://hub.prowler.com/check/admincenter_users_admins_reduced_license_footprint" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/admincenter/admincenter_users_between_two_and_four_global_admins/admincenter_users_between_two_and_four_global_admins.metadata.json b/prowler/providers/m365/services/admincenter/admincenter_users_between_two_and_four_global_admins/admincenter_users_between_two_and_four_global_admins.metadata.json index b429d3a66b..617ae2a35c 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_users_between_two_and_four_global_admins/admincenter_users_between_two_and_four_global_admins.metadata.json +++ b/prowler/providers/m365/services/admincenter/admincenter_users_between_two_and_four_global_admins/admincenter_users_between_two_and_four_global_admins.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "admincenter_users_between_two_and_four_global_admins", - "CheckTitle": "Ensure that between two and four global admins are designated", + "CheckTitle": "Tenant has between two and four Global Administrators", "CheckType": [], "ServiceName": "admincenter", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Active users", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that there are between two and four global administrators designated in your tenant. This ensures monitoring, redundancy, and reduces the risk associated with having too many privileged accounts.", - "Risk": "Having only one global administrator increases the risk of unmonitored actions and operational disruptions if that administrator is unavailable. Having more than four increases the likelihood of a breach through one of these highly privileged accounts.", - "RelatedUrl": "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", + "Description": "Count of users assigned the **Global Administrator** role in the **Microsoft 365 tenant** is between `2` and `4` (inclusive).", + "Risk": "Having 1 **Global Administrator** jeopardizes availability if that account is unavailable or locked out. Having 5 expands the attack surface; one compromised admin can enable tenant-wide changes, data exfiltration, and privilege escalation, impacting confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://admin.microsoft.com", + "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/entra/identity/role-based-access-control/manage-roles-portal" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "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 Global Admins: 1. Select User. 2. Under Roles select Manage roles. 3. De-Select the appropriate role. 4. Click Save changes.", - "Terraform": "" + "Other": "1. Go to https://entra.microsoft.com and sign in as Privileged Role Administrator or Global Administrator\n2. Navigate to Entra ID > Roles & admins > Global Administrator\n3. Select Assignments\n4. To add: click Add assignments, select user(s), click Add; repeat until total Global Administrators is between 2 and 4\n5. To remove: select extra assignment(s), click Remove, confirm\n6. Verify the Assignments count shows between 2 and 4", + "Terraform": "```hcl\n# Assign the Global Administrator role to a specific principal (user or group)\n# Critical: Looks up the built-in Global Administrator role\ndata \"azuread_directory_role\" \"global_admin\" {\n display_name = \"Global Administrator\"\n}\n\n# Critical: Creates the role assignment so the principal becomes a Global Administrator\n# This helps reach the recommended 2-4 Global Administrators\nresource \"azuread_directory_role_assignment\" \"\" {\n role_id = data.azuread_directory_role.global_admin.object_id # Critical: GA role ID\n principal_object_id = \"\" # Critical: target user/group object ID\n}\n```" }, "Recommendation": { - "Text": "Review the number of global administrators in your tenant. Add or remove global admins as necessary to ensure compliance with the recommended range of two to four.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/manage-roles-portal" + "Text": "Maintain `2-4` standing **Global Administrators** (include two break-glass accounts). Apply **least privilege** using scoped roles and **just-in-time** elevation. Enforce **MFA**, run periodic **access reviews**, and implement **separation of duties** to limit standing privileged exposure.", + "Url": "https://hub.prowler.com/check/admincenter_users_between_two_and_four_global_admins" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.metadata.json b/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.metadata.json index e312a637e9..1572e0010b 100644 --- a/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.metadata.json +++ b/prowler/providers/m365/services/defender/defender_antiphishing_policy_configured/defender_antiphishing_policy_configured.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "defender_antiphishing_policy_configured", - "CheckTitle": "Ensure anti-phishing policies are properly configured and active.", + "CheckTitle": "Defender anti-phishing policy active, quarantines spoofed senders and DMARC reject/quarantine failures, honors DMARC policy, safety tips enabled", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Defender Anti-Phishing Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Ensure that anti-phishing policies are created and configured for specific users, groups, or domains, taking precedence over the default policy. This check verifies the existence of rules within policies and validates specific policy settings such as spoof intelligence, DMARC actions, safety tips, and unauthenticated sender actions.", - "Risk": "Without anti-phishing policies, organizations may rely solely on default settings, which might not adequately protect against phishing attacks targeted at specific users, groups, or domains. This increases the risk of successful phishing attempts and potential data breaches.", - "RelatedUrl": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/set-up-anti-phishing-policies?view=o365-worldwide", + "Description": "**Microsoft Defender for Office 365 anti-phishing policies** are evaluated for custom scoping to users, groups, or domains and precedence over the default, plus key settings: **spoof intelligence**, DMARC honoring, `quarantine` actions for spoof/DMARC, **safety tips**, unauthenticated sender indicators, and policy enablement.", + "Risk": "Missing or lax configuration lets **spoofed** and **impersonated** emails reach inboxes. Ignoring DMARC or not using `quarantine` enables delivery of fraudulent messages, driving **credential theft**, **BEC**, and **account takeover**, compromising data **confidentiality** and **integrity** and enabling lateral movement via mailbox rule abuse.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/set-up-anti-phishing-policies?view=o365-worldwide", + "https://learn.microsoft.com/en-us/defender-office-365/anti-phishing-policies-mdo-configure", + "https://learn.microsoft.com/en-us/defender-office-365/anti-phishing-policies-about" + ], "Remediation": { "Code": { - "CLI": "$params = @{Name='';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; New-AntiPhishRule -Name $params.Name -AntiPhishPolicy $params.Name -RecipientDomainIs (Get-AcceptedDomain).Name -Priority 0", + "CLI": "", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies, select Anti-phishing 5. Ensure policies have rules with the state set to 'on' and validate settings: spoof intelligence enabled, spoof intelligence action set to 'Quarantine', DMARC reject and quarantine actions, safety tips enabled, unauthenticated sender action enabled, show tag enabled, and honor DMARC policy enabled. If not, modify them to be as recommended.", + "Other": "1. Go to Microsoft 365 Defender: https://security.microsoft.com > Email & collaboration > Policies & rules > Threat policies > Anti-phishing\n2. Open the Default anti-phishing policy and click Edit\n3. Spoof settings: ensure Enable spoof intelligence is On and set If the message is detected as spoof by spoof intelligence to Quarantine\n4. DMARC: turn On Honor DMARC record policy and set both actions to Quarantine:\n - If DMARC policy is p=quarantine: Quarantine\n - If DMARC policy is p=reject: Quarantine\n5. Safety tips & indicators: turn On Show first contact safety tip, Show (?) for unauthenticated senders for spoof, and Show \"via\" tag\n6. Save changes\n7. If using custom anti-phishing policies, ensure their rule Status is On", "Terraform": "" }, "Recommendation": { - "Text": "Create and configure anti-phishing policies for specific users, groups, or domains to enhance protection against phishing attacks.", - "Url": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/set-up-anti-phishing-policies?view=o365-worldwide" + "Text": "Apply **defense in depth** for email:\n- Create high-priority custom policies for sensitive users/groups/domains\n- Enable **spoof intelligence**; honor DMARC (`p=quarantine`, `p=reject`) with `quarantine` actions\n- Turn on **safety tips** and unauthenticated sender tags\n- Review policy precedence, scope, and thresholds regularly to minimize false positives", + "Url": "https://hub.prowler.com/check/defender_antiphishing_policy_configured" } }, "Categories": [ + "email-security", "e5" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_empty_ip_allowlist/defender_antispam_connection_filter_policy_empty_ip_allowlist.metadata.json b/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_empty_ip_allowlist/defender_antispam_connection_filter_policy_empty_ip_allowlist.metadata.json index 17907a0bc7..fc7d6676cb 100644 --- a/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_empty_ip_allowlist/defender_antispam_connection_filter_policy_empty_ip_allowlist.metadata.json +++ b/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_empty_ip_allowlist/defender_antispam_connection_filter_policy_empty_ip_allowlist.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "defender_antispam_connection_filter_policy_empty_ip_allowlist", - "CheckTitle": "Ensure the Anti-Spam Connection Filter Policy IP Allowlist is empty or undefined.", + "CheckTitle": "Defender Antispam Connection Filter Policy IP Allowlist is empty or undefined", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Defender Anti-Spam Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "This check focuses on Microsoft 365 organizations with Exchange Online mailboxes or standalone Exchange Online Protection (EOP) organizations. It ensures that the connection filter policy's IP Allowlist is empty or undefined to prevent bypassing spam filtering and sender authentication checks, which could lead to successful delivery of malicious emails.", - "Risk": "Using the IP Allowlist without additional verification like mail flow rules poses a risk, as emails from these sources skip essential security checks (SPF, DKIM, DMARC). This could allow attackers to deliver harmful emails directly to the Inbox.", + "Description": "**Microsoft Defender connection filter policy** is evaluated to determine whether the **IP Allowlist** (`IPAllowList`) is configured. The finding indicates if any IP addresses are present in the policy's allow list for Exchange Online or standalone EOP environments.", + "Risk": "Allowlisted IPs bypass **SPF**, **DKIM**, **DMARC** and antispam, enabling **phishing** and **spoofing** that steal credentials (confidentiality), deliver **malware** or fraudulent mail (integrity), and cause **inbox flooding** (availability). Attackers can abuse compromised or shared relays on those IPs to deliver malicious mail.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps" + ], "Remediation": { "Code": { - "CLI": "Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @{}", + "CLI": "Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList $null", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies, select Anti-spam and click on the Connection filter policy (Default). 5. Remove IP entries from the allow list. 6. Click Save.", + "Other": "1. Go to https://security.microsoft.com and sign in\n2. Email & collaboration > Policies & rules > Threat policies\n3. Open Anti-spam > Connection filter policy (Default)\n4. Edit the IP allow list and remove all entries\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that the IP Allowlist in your connection filter policy is empty or undefined to prevent bypassing essential security checks.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps" + "Text": "Keep the **IP Allowlist** empty. Rely on layered controls: **SPF**, **DKIM**, **DMARC**, connection filtering, and content scanning. *If an exception is unavoidable*, apply **defense in depth** (sender auth, TLS, reputation checks, and tight scope/time limits) and prefer domain- or certificate-based trust over static IPs. Monitor delivery logs for abuse.", + "Url": "https://hub.prowler.com/check/defender_antispam_connection_filter_policy_empty_ip_allowlist" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_safe_list_off/defender_antispam_connection_filter_policy_safe_list_off.metadata.json b/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_safe_list_off/defender_antispam_connection_filter_policy_safe_list_off.metadata.json index 202b67c678..fc99632352 100644 --- a/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_safe_list_off/defender_antispam_connection_filter_policy_safe_list_off.metadata.json +++ b/prowler/providers/m365/services/defender/defender_antispam_connection_filter_policy_safe_list_off/defender_antispam_connection_filter_policy_safe_list_off.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "defender_antispam_connection_filter_policy_safe_list_off", - "CheckTitle": "Ensure the default connection filter policy has the SafeList setting disabled", + "CheckTitle": "Defender Antispam Connection Filter Policy has Safe List disabled", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Defender Anti-Spam Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "This check ensures that the EnableSafeList setting in the default connection filter policy is set to False. The safe list, managed dynamically by Microsoft, allows emails from listed IPs to bypass spam filtering and sender authentication checks, posing a security risk.", - "Risk": "If the safe list is enabled, emails from IPs on this list can bypass essential security checks (SPF, DKIM, DMARC), potentially allowing malicious emails to be delivered directly to users' inboxes.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/connection-filter-policies-configure", + "Description": "**Microsoft Defender for Office 365 connection filter policy** safe list setting is evaluated. When enabled, mail from Microsoft-managed IPs skips spam filtering and some sender authentication. The finding indicates whether this implicit bypass is turned off.", + "Risk": "With the safe list on, inbound mail can bypass SPF/DKIM/DMARC and spam heuristics, allowing spoofed or phishing messages to reach inboxes. This risks credential theft (confidentiality), enables account takeover and tampering (integrity), and may lead to malware-driven outages (availability).", + "RelatedUrl": "", + "AdditionalURLs": [ + "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" + ], "Remediation": { "Code": { "CLI": "Set-HostedConnectionFilterPolicy -Identity Default -EnableSafeList $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select Policies & rules. 3. On the Policies & rules page select Threat policies. 4. Under Policies, select Anti-spam and click on the Connection filter policy (Default). 5. Disable the safe list option. 6. Click Save.", + "Other": "1. Go to https://security.microsoft.com/antispam\n2. Select Connection filter policy (Default)\n3. Click Edit connection filter policy\n4. Uncheck Turn on safe list\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that the EnableSafeList setting in your connection filter policy is set to False to prevent bypassing essential security checks.", - "Url": "https://learn.microsoft.com/en-us/defender-office-365/create-safe-sender-lists-in-office-365#use-the-ip-allow-list" + "Text": "Disable the **safe list** (`EnableSafeList=false`). Favor **allow-by-exception**: use the **Tenant Allow/Block List** or tightly scoped IPs only when necessary and validated by strong **email authentication**. Apply **least privilege**, review exceptions regularly, and layer **defense in depth** with anti-phishing and monitoring.", + "Url": "https://hub.prowler.com/check/defender_antispam_connection_filter_policy_safe_list_off" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_antispam_outbound_policy_configured/defender_antispam_outbound_policy_configured.metadata.json b/prowler/providers/m365/services/defender/defender_antispam_outbound_policy_configured/defender_antispam_outbound_policy_configured.metadata.json index a0b33c0449..1844220471 100644 --- a/prowler/providers/m365/services/defender/defender_antispam_outbound_policy_configured/defender_antispam_outbound_policy_configured.metadata.json +++ b/prowler/providers/m365/services/defender/defender_antispam_outbound_policy_configured/defender_antispam_outbound_policy_configured.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "defender_antispam_outbound_policy_configured", - "CheckTitle": "Ensure Defender Outbound Spam Policies are set to notify administrators.", + "CheckTitle": "Defender outbound spam policy is configured to notify recipients when senders are blocked or exceed sending limits", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Defender Anti-Spam Outbound Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Ensure that outbound anti-spam policies are configured to notify administrators and copy suspicious outbound messages to designated recipients when a sender is blocked for sending spam emails.", - "Risk": "Without outbound spam notifications and message copies, compromised accounts may go undetected, increasing the risk of reputation damage or data leakage through unauthorized email activity.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about", + "Description": "**Microsoft Defender for Office 365 outbound spam policies** must send **administrator alerts** and Bcc **suspicious outbound messages** when a sender exceeds limits or is blocked. The assessment checks for `notify limit exceeded` and `notify sender blocked` with recipient addresses in the default policy and any applicable custom policies.", + "Risk": "Absent alerts and copies, **compromised mailboxes** can exfiltrate data and send phishing undetected. This harms **email deliverability** through blocklisting and throttling (**availability**), undermines domain **integrity**, and impedes **forensics** by removing evidence needed to triage abusive outbound traffic.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about", + "https://learn.microsoft.com/is-is/defender-office-365/outbound-spam-policies-configure" + ], "Remediation": { "Code": { - "CLI": "$BccEmailAddress = @(\"\")\n$NotifyEmailAddress = @(\"\")\nSet-HostedOutboundSpamFilterPolicy -Identity Default -BccSuspiciousOutboundAdditionalRecipients $BccEmailAddress -BccSuspiciousOutboundMail $true -NotifyOutboundSpam $true -NotifyOutboundSpamRecipients $NotifyEmailAddress", + "CLI": "Set-HostedOutboundSpamFilterPolicy -Identity Default -BccSuspiciousOutboundMail $true -BccSuspiciousOutboundAdditionalRecipients \"\" -NotifyOutboundSpam $true -NotifyOutboundSpamRecipients \"\"", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select 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' and enter the email addresses. 7. Check 'Notify these users and groups if a sender is blocked due to sending outbound spam' and enter the desired email addresses. 8. Click Save.", + "Other": "1. Sign in to Microsoft 365 Defender: https://security.microsoft.com\n2. Go to Email & collaboration > Policies & rules > Threat policies > Anti-spam\n3. Open Anti-spam outbound policy (Default) and select Edit protection settings\n4. Under Notifications:\n - Check \"Send a copy of suspicious outbound messages or messages that exceed these limits to these users and groups\" and add \n - Check \"Notify these users and groups if a sender is blocked due to sending outbound spam\" and add \n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Configure Defender outbound spam filter policies to notify administrators and copy suspicious outbound messages when users are blocked for sending spam.", - "Url": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about" + "Text": "Enable outbound spam notifications and Bcc suspicious messages to a monitored mailbox, applying them consistently to default and scoped policies. Set prudent sending limits and block actions, disable unnecessary external forwarding, and monitor alerts-aligning with **least privilege** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/defender_antispam_outbound_policy_configured" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_antispam_outbound_policy_forwarding_disabled/defender_antispam_outbound_policy_forwarding_disabled.metadata.json b/prowler/providers/m365/services/defender/defender_antispam_outbound_policy_forwarding_disabled/defender_antispam_outbound_policy_forwarding_disabled.metadata.json index bcb9f406cf..98158f8af3 100644 --- a/prowler/providers/m365/services/defender/defender_antispam_outbound_policy_forwarding_disabled/defender_antispam_outbound_policy_forwarding_disabled.metadata.json +++ b/prowler/providers/m365/services/defender/defender_antispam_outbound_policy_forwarding_disabled/defender_antispam_outbound_policy_forwarding_disabled.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "defender_antispam_outbound_policy_forwarding_disabled", - "CheckTitle": "Ensure Defender Outbound Spam Policies are set to disable mail forwarding.", + "CheckTitle": "Defender Outbound Spam policy disables mail forwarding", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Defender Anti-Spam Outbound Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Ensure Defender Outbound Spam Policies are set to disable mail forwarding.", - "Risk": "Enabling email auto-forwarding can be exploited by attackers or malicious insiders to exfiltrate sensitive data outside the organization, often without detection.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about", + "Description": "**Microsoft Defender for Office 365 outbound spam policies** are evaluated to confirm that automatic mail forwarding is disabled in the default policy and in any custom policies applied to users, groups, or domains.", + "Risk": "Allowing **automatic forwarding** enables covert **data exfiltration**, eroding **confidentiality**. Attackers or insiders can auto-route mail to external inboxes, persist access, evade monitoring, and harvest sensitive content (tickets, approvals, MFA codes), enabling **lateral movement** and fraud while reducing auditability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about" + ], "Remediation": { "Code": { - "CLI": "Set-HostedOutboundSpamFilterPolicy -Identity {policyName} -AutoForwardingMode Off", + "CLI": "Set-HostedOutboundSpamFilterPolicy -Identity -AutoForwardingMode Off", "NativeIaC": "", - "Other": "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.", + "Other": "1. Sign in to https://security.microsoft.com\n2. Go to Email & collaboration > Policies & rules > Threat policies > Anti-spam\n3. Open Anti-spam outbound policy (Default) or the target custom policy\n4. Click Edit protection settings and set Automatic forwarding rules to Off - Forwarding is disabled, then Save\n5. For custom policies, ensure the policy Status is On (enabled); repeat for any additional policies", "Terraform": "" }, "Recommendation": { - "Text": "Block all forms of mail forwarding using Anti-spam outbound policies in Exchange Online. Apply exclusions only where justified by organizational policy.", - "Url": "https://learn.microsoft.com/en-us/defender-office-365/outbound-spam-protection-about" + "Text": "Disable **automatic forwarding** globally in outbound spam policies to enforce **least privilege** on data flows. *If exceptions are required*, restrict to named senders or domains, document approvals, and review regularly. Add **DLP**, alerts on new forwarding rules, and mailbox auditing for **defense in depth**.", + "Url": "https://hub.prowler.com/check/defender_antispam_outbound_policy_forwarding_disabled" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.metadata.json b/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.metadata.json index 8413a8fc82..ed178c87f1 100644 --- a/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.metadata.json +++ b/prowler/providers/m365/services/defender/defender_antispam_policy_inbound_no_allowed_domains/defender_antispam_policy_inbound_no_allowed_domains.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "defender_antispam_policy_inbound_no_allowed_domains", - "CheckTitle": "Ensure inbound anti-spam policies do not contain allowed domains", + "CheckTitle": "Inbound anti-spam policy does not contain allowed domains", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Defender Anti-Spam Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Ensure that inbound anti-spam policies do not have any domains listed in the AllowedSenderDomains. Messages from these domains bypass most email protections, increasing the risk of successful phishing attacks.", - "Risk": "Having domains in the AllowedSenderDomains list allows emails from these domains to bypass essential security checks, increasing the risk of phishing attacks and other malicious activities.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/anti-spam-protection-about#allow-and-block-lists-in-anti-spam-policies", + "Description": "**Microsoft Defender for Office 365 inbound anti-spam policies** are evaluated for domains listed in `AllowedSenderDomains`.\n\nThe finding identifies any policy where this list is populated rather than empty.", + "Risk": "Populating `AllowedSenderDomains` makes messages from those domains skip **spam filtering** and **email authentication** (SPF, DKIM, DMARC), often delivered with SCL `-1`. Attackers can spoof such domains to phish credentials, enable BEC, and alter mailboxes, undermining **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/set-hostedcontentfilterpolicy?view=exchange-ps", + "https://learn.microsoft.com/en-us/defender-office-365/anti-spam-policies-configure", + "https://learn.microsoft.com/en-us/defender-office-365/anti-spam-protection-about#allow-and-block-lists-in-anti-spam-policies" + ], "Remediation": { "Code": { - "CLI": "Set-HostedContentFilterPolicy -Identity -AllowedSenderDomains @{}", + "CLI": "Set-HostedContentFilterPolicy -Identity -AllowedSenderDomains $null", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 Defender (https://security.microsoft.com). 2. Click to expand Email & collaboration and select 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.", + "Other": "1. Open Microsoft 365 Defender: https://security.microsoft.com/antispam\n2. Open each inbound anti-spam policy (Default and any custom).\n3. Click Edit allowed and blocked senders and domains.\n4. Select Allow domains.\n5. Remove all domains, then click Done and Save.\n6. Repeat for any remaining inbound anti-spam policies.", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that the AllowedSenderDomains list in your inbound anti-spam policies is empty to prevent bypassing essential security checks.", - "Url": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/configure-the-allowed-sender-domains?view=o365-worldwide" + "Text": "- Keep `AllowedSenderDomains` empty.\n- Use narrowly scoped allow logic that requires authentication alignment and additional conditions (sender, IP, headers).\n- Make any exceptions temporary and reviewed.\n\nApply **least privilege** and **defense in depth** to email trust decisions.", + "Url": "https://hub.prowler.com/check/defender_antispam_policy_inbound_no_allowed_domains" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.metadata.json b/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.metadata.json index 5b69d64b5f..f485b342f3 100644 --- a/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.metadata.json +++ b/prowler/providers/m365/services/defender/defender_atp_safe_attachments_and_docs_configured/defender_atp_safe_attachments_and_docs_configured.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Defender ATP Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "**Safe Attachments** for SharePoint, OneDrive, and Microsoft Teams protects organizations from inadvertently sharing malicious files. When enabled, files identified as malicious are blocked and cannot be opened, copied, moved, or shared until further actions are taken by the organization's security team.", + "Description": "**Microsoft Defender for Office 365 Safe Attachments** for SharePoint, OneDrive, and Microsoft Teams protects organizations from inadvertently sharing malicious files. When enabled, files identified as malicious are blocked and cannot be opened, copied, moved, or shared until further actions are taken by the organization's security team.", "Risk": "Without **Safe Attachments** enabled, users may download, sync, or access **malicious files** from SharePoint, OneDrive, or Teams, potentially leading to **malware infections** across the organization. If **Safe Documents** is disabled or users can bypass **Protected View**, malicious content in Office documents may execute and **compromise endpoints**.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/defender/defender_chat_report_policy_configured/defender_chat_report_policy_configured.metadata.json b/prowler/providers/m365/services/defender/defender_chat_report_policy_configured/defender_chat_report_policy_configured.metadata.json index feb96141a4..20428b2ae5 100644 --- a/prowler/providers/m365/services/defender/defender_chat_report_policy_configured/defender_chat_report_policy_configured.metadata.json +++ b/prowler/providers/m365/services/defender/defender_chat_report_policy_configured/defender_chat_report_policy_configured.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "defender_chat_report_policy_configured", - "CheckTitle": "Ensure chat report submission policy is properly configured in Defender", + "CheckTitle": "Defender report submission policy uses customized addresses for junk, not junk and phish, and chat reports are sent only to a customized address", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Defender Report Submission Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Ensure Defender report submission policy is properly configured to use customized addresses and enable chat message reporting to customized addresses, while disabling report chat message to Microsoft.", - "Risk": "If Defender report submission policy is not properly configured, reported messages from Teams may not be handled or routed correctly, reducing the organization's ability to respond to threats.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide", + "Description": "**Microsoft Defender for Office 365** user-reported settings ensure `junk`, `not-junk`, and `phish` reports are sent to **customized addresses** with valid destinations, and that **Teams chat reports** route to customized addresses while direct chat reporting to Microsoft is disabled.", + "Risk": "Misrouted or disabled user reports reduce **visibility** into Teams threats, delaying containment. Attackers can keep distributing **phishing links** or **malicious files**, causing credential theft (**confidentiality**), message manipulation (**integrity**), and channel disruption from ongoing spam (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide" + ], "Remediation": { "Code": { - "CLI": "Set-ReportSubmissionPolicy -Identity DefaultReportSubmissionPolicy -EnableReportToMicrosoft $false -ReportChatMessageEnabled $false -ReportChatMessageToCustomizedAddressEnabled $true -ReportJunkToCustomizedAddress $true -ReportNotJunkToCustomizedAddress $true -ReportPhishToCustomizedAddress $true -ReportJunkAddresses $usersub -ReportNotJunkAddresses $usersub -ReportPhishAddresses $usersub", + "CLI": "Set-ReportSubmissionPolicy -Identity DefaultReportSubmissionPolicy -ReportJunkToCustomizedAddress $true -ReportNotJunkToCustomizedAddress $true -ReportPhishToCustomizedAddress $true -ReportJunkAddresses -ReportNotJunkAddresses -ReportPhishAddresses -ReportChatMessageEnabled $false -ReportChatMessageToCustomizedAddressEnabled $true", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 Defender (https://security.microsoft.com/). 2. Click on Settings > Email & collaboration > User reported settings. 3. Scroll to Microsoft Teams section. 4. Ensure Monitor reported messages in Microsoft Teams is checked. 5. Ensure Send reported messages to: is set to My reporting mailbox only with report email addresses defined for authorized staff.", + "Other": "1. Go to Microsoft 365 Defender: https://security.microsoft.com\n2. Navigate to Settings > Email & collaboration > User reported settings\n3. In Reported message destinations (Outlook):\n - Turn on Send Junk to a customized address and enter \n - Turn on Send Not junk to a customized address and enter \n - Turn on Send Phish to a customized address and enter \n4. In Microsoft Teams section:\n - Turn off Monitor reported messages in Microsoft Teams\n - Turn on Send reported Teams messages to a customized address", "Terraform": "" }, "Recommendation": { - "Text": "Configure Defender report submission policy to use customized addresses and enable chat message reporting to customized addresses, while disabling report chat message to Microsoft.", - "Url": "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide" + "Text": "Send all user-reported `junk`, `not-junk`, and `phish` to monitored **custom mailboxes** and enable **Teams chat reporting** to those addresses, keeping direct chat submissions to Microsoft disabled. Apply **least privilege** to reviewer access, establish a **triage workflow**, and integrate alerts for **defense in depth**.", + "Url": "https://hub.prowler.com/check/defender_chat_report_policy_configured" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_domain_dkim_enabled/defender_domain_dkim_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_domain_dkim_enabled/defender_domain_dkim_enabled.metadata.json index 93921a4e0c..a57bce662a 100644 --- a/prowler/providers/m365/services/defender/defender_domain_dkim_enabled/defender_domain_dkim_enabled.metadata.json +++ b/prowler/providers/m365/services/defender/defender_domain_dkim_enabled/defender_domain_dkim_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "defender_domain_dkim_enabled", - "CheckTitle": "Ensure that DKIM is enabled for all Exchange Online Domains", + "CheckTitle": "Exchange Online domain has DKIM enabled", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Online Domain", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "This check ensures that DomainKeys Identified Mail (DKIM) is enabled for all Exchange Online domains. DKIM is a crucial authentication method that, along with SPF and DMARC, helps prevent attackers from sending spoofed emails that appear to originate from your domain. By adding a digital signature to outbound emails, DKIM allows receiving email systems to verify the legitimacy of incoming messages.", - "Risk": "If DKIM is not enabled, attackers may send spoofed emails that appear to originate from your domain, potentially leading to phishing attacks and damage to your domain's reputation.", - "RelatedUrl": "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/use-dkim-to-validate-outbound-email?view=o365-worldwide", + "Description": "**Microsoft 365 Exchange Online domains** use **DKIM signing** for outbound mail. This evaluates each domain to confirm an active DKIM configuration so messages include a verifiable signature via the domain's DKIM selectors.", + "Risk": "Without **DKIM**, recipients can't verify sender authenticity, enabling **domain spoofing** and **BEC phishing**.\n\nAttackers can impersonate trusted mail to steal credentials, deliver malware, and pivot internally, impacting **confidentiality** and **integrity**. Messages may also fail **DMARC** alignment, reducing deliverability and trust.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/set-dkimsigningconfig?view=exchange-ps", + "https://learn.microsoft.com/en-us/powershell/module/exchange/set-dkimsigningconfig?view=exchange-ps", + "https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/use-dkim-to-validate-outbound-email?view=o365-worldwide" + ], "Remediation": { "Code": { - "CLI": "Set-DkimSigningConfig -Identity -Enabled $True", + "CLI": "Set-DkimSigningConfig -Identity -Enabled $true", "NativeIaC": "", - "Other": "1. After DNS records are created, enable DKIM signing in Microsoft 365 Defender. 2. Navigate to Microsoft 365 Defender at https://security.microsoft.com/. 3. Go to Email & collaboration > Policies & rules > Threat policies. 4. Under Rules, select Email authentication settings. 5. Choose DKIM, click on each domain, and enable 'Sign messages for this domain with DKIM signature'.", + "Other": "1. Sign in to Microsoft 365 Defender: https://security.microsoft.com\n2. Go to Email & collaboration > Policies & rules > Threat policies > Email authentication settings > DKIM\n3. Select the domain and enable \"Sign messages for this domain with DKIM signatures\"\n4. If prompted for CNAMEs, publish the two records shown at your DNS provider, wait for DNS to update, then return and enable in step 3", "Terraform": "" }, "Recommendation": { - "Text": "Enable DKIM for all your Exchange Online domains to ensure emails are cryptographically signed and to protect against email spoofing.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-dkimsigningconfig?view=exchange-ps" + "Text": "- Enable **DKIM signing** for all sending domains and subdomains\n- Combine with **SPF** and **DMARC** to enforce alignment (defense in depth)\n- Apply **least privilege** to mail auth settings\n- Rotate DKIM keys regularly and monitor authentication results to detect anomalies", + "Url": "https://hub.prowler.com/check/defender_domain_dkim_enabled" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.metadata.json index 7a2a731f82..d581e9fc0c 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.metadata.json +++ b/prowler/providers/m365/services/defender/defender_malware_policy_common_attachments_filter_enabled/defender_malware_policy_common_attachments_filter_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "defender_malware_policy_common_attachments_filter_enabled", - "CheckTitle": "Ensure the Common Attachment Types Filter is enabled.", + "CheckTitle": "Defender malware policy has Common Attachment Types Filter enabled", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Defender Malware Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Ensure that the Common Attachment Types Filter is enabled in anti-malware policies to block known and custom malicious file types from being attached to emails.", - "Risk": "If this setting is not enabled, users may receive emails with malicious attachments that could contain malware, increasing the risk of endpoint infection or data compromise.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-policies-configure?view=o365-worldwide", + "Description": "**Microsoft Defender for Office 365 anti-malware policies** use the **Common Attachment Types Filter** to block risky file formats regardless of extension. The evaluation checks whether this filter is enabled across default and custom policies and considers policy precedence that could override or bypass the protection.", + "Risk": "Without consistent **attachment type blocking**, malicious `exe`, `js`, `iso`, or `zip` payloads can reach users, enabling code execution and phishing kits.\n- Confidentiality: data exfiltration\n- Integrity: credential theft/tampering\n- Availability: ransomware\n\nPolicy scope/priority gaps can leave specific users unprotected.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/common-attachment-blocking-scenarios", + "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps", + "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-policies-configure?view=o365-worldwide" + ], "Remediation": { "Code": { - "CLI": "Set-MalwareFilterPolicy -Identity Default -EnableFileFilter $true", + "CLI": "Set-MalwareFilterPolicy -Identity -EnableFileFilter $true", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select 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, scroll to the bottom and click Edit protection settings. 6. Check the option Enable the common attachments filter. 7. Click Save.", + "Other": "1. Sign in to Microsoft 365 Defender: https://security.microsoft.com\n2. Go to Email & collaboration > Policies & rules > Threat policies > Anti-malware\n3. Open the failing anti-malware policy (e.g., Default or the named custom policy)\n4. Click Edit protection settings\n5. Enable \"Enable the common attachments filter\"\n6. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable the common attachment types filter in your default or custom anti-malware policy to prevent the delivery of emails with potentially dangerous attachments.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps" + "Text": "Enable the **Common Attachment Types Filter** in all applicable anti-malware policies and choose a strict action (e.g., `Quarantine` or `Reject`).\n- Block high-risk formats; review the list regularly\n- Align policy precedence to cover every recipient\n- Use defense-in-depth: **Safe Attachments**, **Safe Links**, **ZAP**; apply **least privilege** to file types", + "Url": "https://hub.prowler.com/check/defender_malware_policy_common_attachments_filter_enabled" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.metadata.json b/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.metadata.json index 3b3616d27f..0b0f2f8d67 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.metadata.json +++ b/prowler/providers/m365/services/defender/defender_malware_policy_comprehensive_attachments_filter_applied/defender_malware_policy_comprehensive_attachments_filter_applied.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "defender_malware_policy_comprehensive_attachments_filter_applied", - "CheckTitle": "Ensure the Common Attachment Types Filter is enabled and applied in a comprehensive way", + "CheckTitle": "Defender anti-malware policy has Common Attachment Types Filter enabled and blocks all recommended file types", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Defender Malware Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Ensure that the Common Attachment Types Filter is enabled in all enabled anti-malware policies in a Comprehensive way to block known and custom malicious file types from being attached to emails. This means that the file types that the filter blocks are checked by the organization, by default all the default file types from M365 defender should be blocked but you can change that with the config file.", - "Risk": "If this setting or the policy is not enabled, users may receive emails with malicious attachments that could contain malware, increasing the risk of endpoint infection or data compromise.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-protection-about?view=o365-worldwide#common-attachments-filter-in-anti-malware-policies", + "Description": "**Microsoft Defender anti-malware policies** use the **Common Attachment Types Filter** to block a comprehensive set of risky file extensions. It evaluates whether the filter is enabled and all recommended types are blocked across the default policy and any enabled custom policies, considering scope and precedence.", + "Risk": "Missing or partial blocking of dangerous extensions lets **malicious attachments** reach users, enabling code execution, malware staging, and credential theft. Mis-scoped custom policies can override safer defaults, risking **confidentiality** via data exfiltration and **availability** through ransomware and lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-policies-configure", + "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-protection-about?view=o365-worldwide#common-attachments-filter-in-anti-malware-policies", + "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps" + ], "Remediation": { "Code": { - "CLI": "$Policy = @{Name = 'CIS L2 Attachment Policy'; EnableFileFilter = $true; }; $L2Extensions = @('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'); New-MalwareFilterPolicy @Policy -FileTypes $L2Extensions; $Rule = @{Name = $Policy.Name; Enabled = $false; MalwareFilterPolicy = $Policy.Name; Priority = 0}; New-MalwareFilterRule @Rule", + "CLI": "Set-MalwareFilterPolicy -Identity Default -EnableFileFilter $true -FileTypes 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", "NativeIaC": "", - "Other": "1. Navigate to Microsoft 365 Defender https://security.microsoft.com. 2. Click to expand Email & collaboration and select 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, scroll to the bottom and click Edit protection settings. 6. Check the option Enable the common attachments filter. 7. Click on select file types and select the file types you want to block. 8. Click Save. 9. Ensure the status of the policy is On", + "Other": "1. Go to Microsoft 365 Defender: https://security.microsoft.com\n2. Navigate to Email & collaboration > Policies & rules > Threat policies > Anti-malware\n3. Open Default (Default) policy and select Edit protection settings\n4. Enable \"Enable the common attachments filter\"\n5. Select file types and ensure ALL of these are selected: 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\n6. Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable the common attachment types filter in your default or custom anti-malware policy to prevent the delivery of emails with potentially dangerous attachments.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps" + "Text": "Enable and enforce the **Common Attachment Types Filter** in all anti-malware policies and block the full recommended set. Align custom policy scope and priority to avoid weakening coverage. Apply **least privilege** to exceptions, prefer quarantine, and regularly review/expand blocked types. Use ZAP and monitoring for **defense-in-depth**.", + "Url": "https://hub.prowler.com/check/defender_malware_policy_comprehensive_attachments_filter_applied" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.metadata.json index 1fc47b03c6..4380f8eed4 100644 --- a/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.metadata.json +++ b/prowler/providers/m365/services/defender/defender_malware_policy_notifications_internal_users_malware_enabled/defender_malware_policy_notifications_internal_users_malware_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "defender_malware_policy_notifications_internal_users_malware_enabled", - "CheckTitle": "Ensure notifications for internal users sending malware is Enabled", + "CheckTitle": "Defender anti-malware policy has admin notifications enabled for internal users sending malware", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Defender Malware Policy", + "Severity": "medium", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Verify that Exchange Online Protection (EOP) is configured to notify admins of malicious activity from internal users.", - "Risk": "If notifications for internal users sending malware are not enabled, administrators may not be aware of potential threats originating from within the organization, increasing the risk of undetected malicious activities.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-protection-about", + "Description": "**Microsoft Defender for Office 365 anti-malware policies** are checked for **admin notifications** on malware detected from **internal senders**, ensuring a valid notification address is defined (`EnableInternalSenderAdminNotifications` and `InternalSenderAdminAddress`).\n\n*Effective settings across default and custom policies are considered.*", + "Risk": "Without these notifications, malware sent from internal accounts can persist unnoticed, delaying response and containment. This undermines **integrity** of email, enables **lateral movement** and **outbound propagation**, and can cause **domain reputation** damage and blocklisting, affecting **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps", + "https://learn.microsoft.com/en-us/defender-office-365/anti-malware-protection-about" + ], "Remediation": { "Code": { - "CLI": "Set-MalwareFilterPolicy -Identity Default -EnableInternalSenderAdminNotifications $true -InternalSenderAdminAddress 'admin@example.com'", + "CLI": "Set-MalwareFilterPolicy -Identity Default -EnableInternalSenderAdminNotifications $true -InternalSenderAdminAddress \"\"", "NativeIaC": "", - "Other": "1. Connect to Exchange Online using Connect-ExchangeOnline. 2. Execute the command: Get-MalwareFilterPolicy | fl Identity, EnableInternalSenderAdminNotifications, InternalSenderAdminAddress. 3. Ensure 'Notify an admin about undelivered messages from internal senders' is set to On and that at least one email address is listed under Administrator email address.", + "Other": "1. In the Microsoft Defender portal (security.microsoft.com), go to Email & collaboration > Policies & rules > Threat policies > Anti-malware\n2. Select the affected policy (e.g., Default) and click Edit policy\n3. Open Notifications\n4. Turn on \"Notify an admin about undelivered messages from internal senders\"\n5. Add at least one Administrator email address\n6. Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable notifications for internal users sending malware in your Defender Malware Policy to ensure admins are alerted of potential threats.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-malwarefilterpolicy?view=exchange-ps" + "Text": "Enable and maintain admin alerts for internal-sender malware and route to a monitored mailbox or SOC list (`EnableInternalSenderAdminNotifications` and `InternalSenderAdminAddress`).\n\nEnsure coverage via policy precedence, integrate with SIEM, and apply **least privilege** and **defense in depth** to limit impact.", + "Url": "https://hub.prowler.com/check/defender_malware_policy_notifications_internal_users_malware_enabled" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json index 0a4176a0df..da2d92d35d 100644 --- a/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json +++ b/prowler/providers/m365/services/defender/defender_safe_attachments_policy_enabled/defender_safe_attachments_policy_enabled.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Defender Safe Attachments Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Safe Attachments provides an additional layer of protection by scanning email attachments in a secure environment before delivering them to recipients.\n\nThe Built-In Protection Policy should have **Enable=True**, **Action=Block**, and **QuarantineTag=AdminOnlyAccessPolicy** to ensure malicious attachments are blocked and quarantined with admin-only access.", + "Description": "**Microsoft Defender for Office 365 Safe Attachments** provides an additional layer of protection by scanning email attachments in a secure environment before delivering them to recipients.\n\nThe Built-In Protection Policy should have **Enable=True**, **Action=Block**, and **QuarantineTag=AdminOnlyAccessPolicy** to ensure malicious attachments are blocked and quarantined with admin-only access.", "Risk": "Without properly configured Safe Attachments policies, malicious email attachments could reach users' mailboxes and potentially compromise the organization through:\n\n- **Malware delivery** via infected documents\n- **Ransomware attacks** through weaponized attachments\n- **Data exfiltration** using malicious scripts", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.metadata.json index cccebcfc7f..0c2c716822 100644 --- a/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.metadata.json +++ b/prowler/providers/m365/services/defender/defender_safelinks_policy_enabled/defender_safelinks_policy_enabled.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Defender Safe Links Policy", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Safe Links is a Microsoft Defender for Office 365 feature that provides URL scanning and rewriting of inbound email messages, as well as time-of-click verification of URLs and links in email messages, Teams, and Office apps.\n\nThis check verifies that the Safe Links policy is properly configured with all recommended settings enabled.", + "Description": "**Microsoft Defender for Office 365 Safe Links** is a feature that provides URL scanning and rewriting of inbound email messages, as well as time-of-click verification of URLs and links in email messages, Teams, and Office apps.\n\nThis check verifies that the Safe Links policy is properly configured with all recommended settings enabled.", "Risk": "Without properly configured Safe Links protection, users may be vulnerable to **phishing attacks** and **malicious URLs** in emails, Teams messages, and Office documents.\n\nAttackers could bypass security by using URLs that redirect to malicious content after initial scanning.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.metadata.json b/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.metadata.json index d2df3f8892..ea46672ece 100644 --- a/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.metadata.json +++ b/prowler/providers/m365/services/defender/defender_zap_for_teams_enabled/defender_zap_for_teams_enabled.metadata.json @@ -7,10 +7,10 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Teams Protection Policy", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Zero-hour auto purge (ZAP) is a protection feature that retroactively detects and neutralizes **malware** and **high confidence phishing** in Teams messages.\n\nWhen ZAP blocks a message, it is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP can occur up to 48 hours after delivery.", - "Risk": "Without ZAP enabled, malicious content delivered to Teams chats remains accessible to users for up to 48 hours after delivery, even after being identified as harmful.\n\nThis extended exposure window could lead to:\n- **Malware infections** from weaponized attachments or links\n- **Phishing attacks** compromising user credentials and MFA tokens\n- **Lateral movement** as attackers exploit compromised accounts within the organization", + "Description": "**Microsoft Defender for Office 365 Zero-hour auto purge (ZAP)** is a protection feature that retroactively detects and neutralizes **malware** and **high confidence phishing** in Teams messages.\n\nWhen ZAP blocks a message, it is blocked for everyone in the chat. The initial block happens right after delivery, but ZAP can occur up to 48 hours after delivery.", + "Risk": "Without ZAP enabled, malicious content in Teams chats remains accessible for up to 48 hours after delivery, even after being identified as harmful. This extended exposure enables **malware infections**, **phishing attacks** compromising credentials and MFA tokens, and **lateral movement** via compromised accounts.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/defender-office-365/zero-hour-auto-purge?view=o365-worldwide#zero-hour-auto-purge-zap-in-microsoft-teams", diff --git a/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.metadata.json b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.metadata.json index deaf9078f8..52b09e830f 100644 --- a/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.metadata.json +++ b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Defender for Identity Health Issue", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Microsoft Defender for Identity (MDI) monitors your hybrid identity infrastructure and detects advanced threats targeting Active Directory. This check verifies that MDI sensors are deployed and that there are no unresolved health issues that may affect the ability to detect identity-based attacks.", + "Description": "**Microsoft Defender for Identity (MDI)** monitors your hybrid identity infrastructure and detects advanced threats targeting Active Directory. This check verifies that MDI sensors are deployed and that there are no unresolved health issues that may affect the ability to detect identity-based attacks.", "Risk": "Without deployed MDI sensors or with unresolved health issues, organizations face critical gaps in threat detection. Misconfigured or missing sensors fail to monitor domain controllers, allowing identity-based attacks like Pass-the-Hash, Golden Ticket, or lateral movement to go undetected. Attackers commonly exploit these blind spots to compromise hybrid environments while evading detection.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals.metadata.json b/prowler/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals.metadata.json index 1d2c58ba2b..4bd6c1a5a3 100644 --- a/prowler/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals.metadata.json +++ b/prowler/providers/m365/services/defenderxdr/defenderxdr_critical_asset_management_pending_approvals/defenderxdr_critical_asset_management_pending_approvals.metadata.json @@ -1,16 +1,16 @@ { "Provider": "m365", "CheckID": "defenderxdr_critical_asset_management_pending_approvals", - "CheckTitle": "Ensure all Critical Asset Management classifications are reviewed and approved in Microsoft Defender XDR", + "CheckTitle": "Critical asset management classifications are reviewed and approved", "CheckType": [], "ServiceName": "defenderxdr", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Defender XDR Critical Asset Management", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Assets with a lower classification confidence score in Microsoft Defender XDR must be approved by a security administrator.\n\nAsset classifications that have not yet been reviewed and approved may result in incomplete **critical asset** visibility.", - "Risk": "Stale pending approvals lead to limited visibility in Microsoft Defender XDR. **Critical assets** that are not properly identified and classified may not receive appropriate security monitoring and protections, creating gaps in the organization's security posture.", + "Description": "**Microsoft Defender XDR critical asset management classifications** with a lower classification confidence score must be approved by a security administrator.\n\nAsset classifications that have not yet been reviewed and approved may result in incomplete **critical asset** visibility.", + "Risk": "Stale pending approvals lead to limited visibility in **Microsoft Defender XDR**. **Critical assets** that are not properly identified and classified may not receive appropriate security monitoring and protections, creating gaps in the organization's security posture.", "RelatedUrl": "", "AdditionalURLs": [ "https://learn.microsoft.com/en-us/security-exposure-management/classify-critical-assets", diff --git a/prowler/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials.metadata.json b/prowler/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials.metadata.json index 3f4efe68c8..4142484eec 100644 --- a/prowler/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials.metadata.json +++ b/prowler/providers/m365/services/defenderxdr/defenderxdr_endpoint_privileged_user_exposed_credentials/defenderxdr_endpoint_privileged_user_exposed_credentials.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Exposure Management", + "ResourceType": "NotDefined", "ResourceGroup": "security", - "Description": "Privileged users may have authentication artifacts (CLI secrets, cookies, tokens) exposed on endpoints with high risk scores. Microsoft Defender XDR's Security Exposure Management detects when credentials from users with Entra ID privileged roles are present on vulnerable devices.", + "Description": "Microsoft Defender XDR's **Security Exposure Management** detects when credentials from users with Entra ID privileged roles are present on vulnerable devices. Privileged users may have authentication artifacts (CLI secrets, cookies, tokens) exposed on endpoints with high risk scores.", "Risk": "Exposed credentials on vulnerable endpoints enable account takeover through stolen tokens or cookies, Conditional Access bypass via primary refresh tokens, lateral movement to sensitive resources, and persistence until tokens are explicitly revoked.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/entra/entra_admin_consent_workflow_enabled/entra_admin_consent_workflow_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_admin_consent_workflow_enabled/entra_admin_consent_workflow_enabled.metadata.json index 40dea22941..1ebc9f5e59 100644 --- a/prowler/providers/m365/services/entra/entra_admin_consent_workflow_enabled/entra_admin_consent_workflow_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_admin_consent_workflow_enabled/entra_admin_consent_workflow_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "entra_admin_consent_workflow_enabled", - "CheckTitle": "Ensure the admin consent workflow is enabled.", + "CheckTitle": "Admin consent workflow is enabled", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Organization Settings", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure that the admin consent workflow is enabled in Microsoft Entra to allow users to request admin approval for applications requiring consent.", - "Risk": "If the admin consent workflow is not enabled, users may be blocked from accessing applications that require admin consent, leading to potential work disruptions or unauthorized workarounds.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow", + "Description": "Microsoft Entra **admin consent workflow** is evaluated to confirm an approval path exists for app permission requests. The check looks for the workflow being enabled and, when present, whether **reviewer notifications** are configured.", + "Risk": "Without an approval workflow, app access decisions lack controlled review. This can force permissive settings or push users to shadow IT, enabling **consent phishing** and excessive Graph permissions that jeopardize **confidentiality** and **integrity**, or block required apps, affecting **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-NZ/entra/identity/enterprise-apps/user-admin-consent-overview", + "https://www.cloudcoffee.ch/microsoft-azure/microsoft-entra-id-admin-consent-workflow/", + "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow", + "https://global-sharepoint.com/sharepoint/admin-consent-approval-workflow/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "Update-MgPolicyAdminConsentRequestPolicy -IsEnabled:$true", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Click to expand Identity > Applications and select Enterprise applications. 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'. 6. Configure the reviewers and email notifications settings. 7. Click Save.", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com) as a Global Administrator\n2. Go to Entra ID > Enterprise applications > Consent and permissions > Admin consent settings\n3. Set \"Users can request admin consent to apps they are unable to consent to\" to Yes\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable the admin consent workflow in Microsoft Entra to securely manage application consent requests.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-admin-consent-workflow" + "Text": "Enable the **admin consent workflow** (`Users can request admin consent to apps they are unable to consent to`) and assign least-privileged reviewers; enable notifications and expiry. Combine with restrictive **user consent** policies, permission classifications, and periodic reviews. Apply **least privilege** and **separation of duties**.", + "Url": "https://hub.prowler.com/check/entra_admin_consent_workflow_enabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_admin_portals_access_restriction/entra_admin_portals_access_restriction.metadata.json b/prowler/providers/m365/services/entra/entra_admin_portals_access_restriction/entra_admin_portals_access_restriction.metadata.json index 6ed0b56cc6..2b20fa91bb 100644 --- a/prowler/providers/m365/services/entra/entra_admin_portals_access_restriction/entra_admin_portals_access_restriction.metadata.json +++ b/prowler/providers/m365/services/entra/entra_admin_portals_access_restriction/entra_admin_portals_access_restriction.metadata.json @@ -1,36 +1,42 @@ { "Provider": "m365", "CheckID": "entra_admin_portals_access_restriction", - "CheckTitle": "Ensure that only administrative roles have access to Microsoft Admin Portals", - "CheckAliases": [ - "entra_admin_portals_role_limited_access" - ], + "CheckTitle": "Admin portals are accessible only to administrative roles", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Conditional Access Policy", + "Severity": "medium", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that only administrative roles have access to Microsoft Admin Portals to prevent unauthorized changes, privilege escalation, and security misconfigurations.", - "Risk": "Allowing non-administrative users to access Microsoft Admin Portals increases the risk of unauthorized changes, privilege escalation, and potential security misconfigurations. Attackers could exploit these privileges to manipulate settings, disable security features, or access sensitive data.", - "RelatedUrl": "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/about-admin-roles?view=o365-worldwide", + "Description": "Microsoft Entra **Conditional Access** restricts `MicrosoftAdminPortals` by targeting admin portals, including all users, excluding administrative roles, and applying a **block** decision. The assessment determines whether an active policy enforces this restriction rather than only reporting.", + "Risk": "Absent this control, non-admin identities can reach admin portals, jeopardizing **integrity** (unauthorized tenant changes), **confidentiality** (exposure of settings and data), and **availability** (disabling services). Threats include privilege escalation, weakening policies, and creating persistence.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/about-admin-roles?view=o365-worldwide", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "New-MgIdentityConditionalAccessPolicy -BodyParameter @{displayName=\"\";state=\"enabled\";conditions=@{users=@{includeUsers=@(\"All\");excludeRoles=@(\"62e90394-69f5-4237-9190-012177145e10\")};applications=@{includeApplications=@(\"MicrosoftAdminPortals\")}};grantControls=@{builtInControls=@(\"block\")}}", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Click New Policy. Under Users include All Users. Under Users select Exclude and check Directory roles and select only administrative roles and a group of PIM eligible users. Under Target resources select Cloud apps and Select apps then select the Microsoft Admin Portals app. Confirm by clicking Select. 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.", - "Terraform": "" + "Other": "1. Go to Microsoft Entra admin center > Protection > Conditional Access > Policies > New policy\n2. Users: Include = All users; Exclude = Directory roles, select all administrative roles\n3. Target resources: Cloud apps > Select apps > choose Microsoft Admin Portals > Select\n4. Grant: Block access > Select\n5. Enable policy: On > Create", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\" # Critical: policy must be enabled to PASS\n\n conditions {\n users {\n include_users = [\"All\"] # Critical: include all users\n exclude_roles = [\"62e90394-69f5-4237-9190-012177145e10\"] # Critical: exclude admin role(s) so only admins can access\n }\n applications {\n included_applications = [\"MicrosoftAdminPortals\"] # Critical: target Microsoft Admin Portals\n }\n }\n\n grant_controls {\n built_in_controls = [\"block\"] # Critical: block non-excluded users\n }\n}\n```" }, "Recommendation": { - "Text": "Enforce Conditional Access policies to restrict Microsoft Admin Portals to predefined administrative roles. Ensure that only necessary users have access to these portals, applying the principle of least privilege and conducting periodic access reviews to maintain security compliance.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview" + "Text": "Enforce **least privilege** with Conditional Access that blocks `MicrosoftAdminPortals` for everyone except approved admin roles. Add **defense in depth**: require strong MFA/authentication strength, compliant devices, and trusted locations; use JIT via PIM. Review role assignments and policies routinely.", + "Url": "https://hub.prowler.com/check/entra_admin_portals_access_restriction" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "entra_admin_portals_role_limited_access" + ] } diff --git a/prowler/providers/m365/services/entra/entra_admin_users_cloud_only/entra_admin_users_cloud_only.metadata.json b/prowler/providers/m365/services/entra/entra_admin_users_cloud_only/entra_admin_users_cloud_only.metadata.json index 5fc9941a78..ceaf551b36 100644 --- a/prowler/providers/m365/services/entra/entra_admin_users_cloud_only/entra_admin_users_cloud_only.metadata.json +++ b/prowler/providers/m365/services/entra/entra_admin_users_cloud_only/entra_admin_users_cloud_only.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "entra_admin_users_cloud_only", - "CheckTitle": "Ensure all Microsoft 365 administrative users are cloud-only", + "CheckTitle": "All users with administrative roles are cloud-only accounts", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Administrative User", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "This check verifies that all Microsoft 365 administrative users are cloud-only, not synchronized from an on-premises directory, by querying administrative users and checking their synchronization status.", - "Risk": "On-premises synchronized administrative users increase the attack surface and compromise the security posture of the cloud environment. Compromise of on-premises systems could lead to unauthorized access to Microsoft 365 administrative functionalities.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#9-use-cloud-native-accounts-for-microsoft-entra-roles", + "Description": "Microsoft Entra **administrative users** are evaluated to confirm they are **cloud-only accounts**, with no on-premises directory synchronization for any user holding privileged roles.", + "Risk": "**On-premises-synced privileged accounts** extend the cloud trust boundary to AD. If AD or the sync channel is compromised, attackers can:\n- **Escalate** into Entra roles\n- Alter tenant settings and access data\n- Maintain **persistence** via on-prem credentials\n\nThis harms **confidentiality** and **integrity** and complicates recovery.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#9-use-cloud-native-accounts-for-microsoft-entra-roles" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "Remove-MgDirectoryRoleMemberByRef -DirectoryRoleId -DirectoryObjectId ", "NativeIaC": "", - "Other": "1. Identify on-premises synchronized administrative users using Microsoft Entra Connect or equivalent tools. 2. Create new cloud-only administrative user with appropriate permissions. 3. Migrate administrative tasks from on-premises synchronized users to the new cloud-only user. 4. Disable or remove the on-premises synchronized administrative users.", + "Other": "1. In the Microsoft Entra admin center, go to Identity > Users. Filter: On-premises sync enabled = Yes. Identify any users with administrative roles. 2. If needed, create a cloud-only admin: Identity > Users > New user > Create user; under Roles, assign the required admin role. 3. Remove admin roles from synchronized users: Identity > Roles & administrators > select the role > Members > select the synchronized user(s) > Remove.", "Terraform": "" }, "Recommendation": { - "Text": "Ensure all Microsoft 365 administrative users are cloud-only to reduce the attack surface and improve security posture.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#9-use-cloud-native-accounts-for-microsoft-entra-roles" + "Text": "Assign Entra roles only to **cloud-native accounts**. Enforce **least privilege**, **MFA**, and **Conditional Access**; use **PIM** for just-in-time elevation. Maintain cloud-only break-glass accounts, perform periodic access reviews, and prohibit synced identities from holding privileged roles for **defense in depth**.", + "Url": "https://hub.prowler.com/check/entra_admin_users_cloud_only" } }, "Categories": [ + "identity-access", + "trust-boundaries", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_admin_users_mfa_enabled/entra_admin_users_mfa_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_admin_users_mfa_enabled/entra_admin_users_mfa_enabled.metadata.json index d682dbf4fa..c3ac8d244e 100644 --- a/prowler/providers/m365/services/entra/entra_admin_users_mfa_enabled/entra_admin_users_mfa_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_admin_users_mfa_enabled/entra_admin_users_mfa_enabled.metadata.json @@ -1,36 +1,45 @@ { "Provider": "m365", "CheckID": "entra_admin_users_mfa_enabled", - "CheckTitle": "Ensure multifactor authentication is enabled for all users in administrative roles.", - "CheckAliases": [ - "entra_admin_mfa_enabled_for_administrative_roles" - ], + "CheckTitle": "Users in administrative roles require multifactor authentication via a Conditional Access policy for all applications", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that multifactor authentication (MFA) is enabled for all users in administrative roles to enhance security and reduce the risk of unauthorized access.", - "Risk": "Without MFA enabled for administrative roles, attackers could compromise privileged accounts with only a single authentication factor, increasing the risk of data breaches and unauthorized access to sensitive resources.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa", + "Description": "Microsoft Entra Conditional Access policies that enforce **multifactor authentication** for users in **administrative roles** across all resources.\n\nThe assessment identifies at least one active policy that targets admin roles (or all users), includes all applications, and grants access only when `Require multifactor authentication` is satisfied.", + "Risk": "Without enforced **MFA** on privileged accounts, stolen or phished passwords can grant admin access, enabling tenant takeover. Attackers may exfiltrate data, change configurations, consent malicious apps, and disable protections, impacting confidentiality, integrity, and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-getstarted", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-alt-all-users-compliant-hybrid-or-mfa", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa", + "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-alt-admin-device-compliand-hybrid", + "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-userstates" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "az rest --method post --url https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies --body '{\"displayName\":\"Require MFA for all users\",\"state\":\"enabled\",\"conditions\":{\"users\":{\"includeUsers\":[\"All\"]},\"applications\":{\"includeApplications\":[\"All\"]}},\"grantControls\":{\"operator\":\"OR\",\"builtInControls\":[\"mfa\"]}}'", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com. 2. Expand Protection > Conditional Access and select Policies. 3. Click 'New policy' and configure: Users: Select users and groups > Directory roles (include admin roles). Target resources: Include 'All cloud apps' with no exclusions. Grant: Select 'Grant Access' and check 'Require multifactor authentication'. 4. Set policy to 'Report Only' for testing before full enforcement. 5. Click 'Create'.", - "Terraform": "" + "Other": "1. Sign in to Microsoft Entra admin center > Entra ID > Protection > Conditional Access > Policies > New policy\n2. Users: Include > All users\n3. Target resources: Include > All cloud apps (All resources)\n4. Grant: Grant access > Require multifactor authentication > Select\n5. Enable policy: On > Create", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"Require MFA for all users\"\n state = \"enabled\" # Critical: policy must be enabled to enforce\n\n conditions {\n users {\n include_users = [\"All\"] # Critical: applies to all users, covering all admin roles\n }\n applications {\n included_applications = [\"All\"] # Critical: targets all cloud apps/resources\n }\n }\n\n grant_controls {\n built_in_controls = [\"mfa\"] # Critical: require multifactor authentication\n operator = \"OR\"\n }\n}\n```" }, "Recommendation": { - "Text": "Enable MFA for all users in administrative roles using a Conditional Access policy in Microsoft Entra.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-admin-mfa" + "Text": "Require **MFA** for all administrative roles with Conditional Access scoped to `All cloud apps` to avoid gaps. Prefer **phishing-resistant** methods (FIDO2, passkeys, Authenticator). Apply least privilege, limit exclusions, protect break-glass accounts, monitor sign-ins, and verify policies actively enforce, not just report.", + "Url": "https://hub.prowler.com/check/entra_admin_users_mfa_enabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "", + "CheckAliases": [ + "entra_admin_mfa_enabled_for_administrative_roles" + ] } diff --git a/prowler/providers/m365/services/entra/entra_admin_users_phishing_resistant_mfa_enabled/entra_admin_users_phishing_resistant_mfa_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_admin_users_phishing_resistant_mfa_enabled/entra_admin_users_phishing_resistant_mfa_enabled.metadata.json index cb78632c5e..9b50c777e6 100644 --- a/prowler/providers/m365/services/entra/entra_admin_users_phishing_resistant_mfa_enabled/entra_admin_users_phishing_resistant_mfa_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_admin_users_phishing_resistant_mfa_enabled/entra_admin_users_phishing_resistant_mfa_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "entra_admin_users_phishing_resistant_mfa_enabled", - "CheckTitle": "Ensure phishing-resistant MFA strength is required for all administrator accounts", + "CheckTitle": "At least one Conditional Access policy requires phishing-resistant MFA strength for administrator roles", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "This check verifies that phishing-resistant MFA strength is required for all administrator accounts. Phishing-resistant MFA includes authentication methods that are resistant to phishing attacks and MFA fatigue attacks compared to weaker methods like SMS or push notifications.", - "Risk": "Administrators using weaker MFA methods, such as SMS or push notifications, are vulnerable to phishing attacks and MFA fatigue attacks. Attackers can intercept codes or trick users into approving fraudulent authentication requests, leading to unauthorized access to critical systems.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-admin-phish-resistant-mfa", + "Description": "Microsoft Entra **Conditional Access** for administrator roles requires **phishing-resistant MFA** authentication strength on `All` applications. Disabled policies are ignored; report-only policies aren't considered. Policies with custom strengths require review to confirm they are truly **phishing-resistant**.", + "Risk": "Without phishing-resistant MFA on admin accounts, attackers can:\n- Bypass OTP/push via **AiTM phishing**\n- Abuse **MFA fatigue** to gain sessions\n- Perform **tenant takeover**, alter policies, and exfiltrate data\n\nThis harms confidentiality, configuration integrity, and service availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://blog.admindroid.com/use-phishing-resistant-mfa-to-implement-stronger-mfa-authentication/", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-admin-phish-resistant-mfa#create-a-conditional-access-policy", + "https://docs.azure.cn/en-us/entra/identity/conditional-access/policy-guests-mfa-strength", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-admin-phish-resistant-mfa" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Click New policy. Under Users include Select users and groups and check Directory roles. At a minimum, include the directory roles listed below in this section of the document. Under Target resources include All cloud apps and do not create any exclusions. Under Grant select Grant Access and check Require authentication strength and set Phishing-resistant MFA in the dropdown box. Click Select. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create.", + "Other": "1. Sign in to Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Entra ID > Conditional Access > Policies > New policy\n3. Users > Include > Directory roles > select Global Administrator (or the admin roles you require)\n4. Target resources > Resources (cloud apps) > Include > All cloud apps; ensure Exclude is empty\n5. Grant > Grant access > Require authentication strength > select Phishing-resistant MFA > Select\n6. Enable policy: On\n7. Click Create", "Terraform": "" }, "Recommendation": { - "Text": "Require phishing-resistant MFA strength for all administrator accounts through Conditional Access policies. Enforce the use of FIDO2 security keys, Windows Hello for Business, or certificate-based authentication. Ensure administrators are pre-registered for these methods before enforcement to prevent lockouts. Maintain a break-glass account exempt from this policy for emergency access.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-admin-phish-resistant-mfa#create-a-conditional-access-policy" + "Text": "Require `Phishing-resistant MFA` via Conditional Access for all privileged roles and `All resources`. Favor **FIDO2**, **Windows Hello for Business**, or **certificate-based auth**. Apply **least privilege**, use **PIM** for step-up on role activation, test in report-only, and keep a monitored break-glass account.", + "Url": "https://hub.prowler.com/check/entra_admin_users_phishing_resistant_mfa_enabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_admin_users_sign_in_frequency_enabled/entra_admin_users_sign_in_frequency_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_admin_users_sign_in_frequency_enabled/entra_admin_users_sign_in_frequency_enabled.metadata.json index 72a7ea8377..541bac256f 100644 --- a/prowler/providers/m365/services/entra/entra_admin_users_sign_in_frequency_enabled/entra_admin_users_sign_in_frequency_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_admin_users_sign_in_frequency_enabled/entra_admin_users_sign_in_frequency_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "entra_admin_users_sign_in_frequency_enabled", - "CheckTitle": "Ensure Sign-in frequency periodic reauthentication is enabled and properly configured.", + "CheckTitle": "Admin users have sign-in frequency enforced by Conditional Access at or below the recommended interval", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure Sign-in frequency periodic reauthentication is enabled and properly configured to reduce the risk of unauthorized access and session hijacking.", - "Risk": "Allowing persistent browser sessions and long sign-in frequencies for administrative users increases the risk of unauthorized access. Attackers could exploit session persistence to maintain access to an admin account without reauthentication, increasing the likelihood of account compromise, especially in cases of credential theft or session hijacking.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-session#sign-in-frequency", + "Description": "Microsoft Entra **Conditional Access** evaluates whether admin roles are covered by policies that enforce a defined **sign-in frequency** and **non-persistent browser sessions** across *all cloud apps*. It looks for reauthentication set to a time interval or `Every time`, persistent browser set to `never`, and policies that are enforced rather than report-only or disabled.", + "Risk": "Lax reauthentication and persistent sessions let admin tokens live too long, enabling **session hijacking**, **token replay**, and access after **credential theft**. Attackers can modify configurations, elevate privileges, and exfiltrate data, threatening **confidentiality** and **integrity** and increasing risk of **tenant takeover**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime#user-sign-in-frequency", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-session#sign-in-frequency" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Click to expand Protection > Conditional Access Select Policies. 3. Click New policy. Under Users include, select users and groups and check Directory roles. At a minimum, include the directory roles listed below in this section of the document. Under Target resources, include All cloud apps and do not create any exclusions. Under Grant, select Grant Access and check Require multifactor authentication. Under Session, select Sign-in frequency, select Periodic reauthentication, and set it to 4 hours for E3 tenants. E5 tenants with PIM can be set to a maximum value of 24 hours. Check Persistent browser session, then select Never persistent in the drop-down menu. 4. Under Enable policy, set it to Report Only until the organization is ready to enable it.", - "Terraform": "" + "Other": "1. Go to Microsoft Entra admin center (https://entra.microsoft.com/)\n2. Navigate to Protection > Conditional Access > Policies > New policy\n3. Users > Include > Select users and groups > Directory roles: select admin roles (e.g., Global Administrator)\n4. Target resources (Cloud apps): Select All cloud apps\n5. Session:\n - Enable Sign-in frequency and set to Every time OR set 4 hours (or less)\n - Set Persistent browser session to Never persistent\n6. Enable policy: On, then Create", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\" # Critical: must be enabled (not report-only) to enforce\n\n conditions {\n users {\n included_roles = [\"\"] # Critical: target admin directory roles (e.g., Global Administrator)\n }\n applications {\n included_applications = [\"All\"] # Critical: apply to all cloud apps\n }\n }\n\n session_controls {\n sign_in_frequency = 4 # Critical: enforce reauth at or below 4 hours\n sign_in_frequency_interval = \"hours\" # Critical: time-based frequency in hours\n persistent_browser_mode = \"never\" # Critical: enforce non-persistent browser sessions\n }\n}\n```" }, "Recommendation": { - "Text": "Enforce a sign-in frequency limit of no more than 4 hours for E3 tenants (or 24 hours for E5 with Privileged Identity Management) and set browser sessions to Never persistent. This ensures that administrative users are regularly reauthenticated, reducing the risk of prolonged unauthorized access and mitigating session hijacking threats.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-session-lifetime#user-sign-in-frequency" + "Text": "Use **Conditional Access** for admin roles to:\n- Enforce short sign-in frequency (e.g., `4` hours, or `Every time` for critical actions)\n- Set persistent browser to `never`\n- Cover all apps and run in enforce mode\n\nPair with **least privilege**, **MFA**, **PIM**, and **token protection** to reduce session abuse.", + "Url": "https://hub.prowler.com/check/entra_admin_users_sign_in_frequency_enabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_all_apps_conditional_access_coverage/entra_all_apps_conditional_access_coverage.metadata.json b/prowler/providers/m365/services/entra/entra_all_apps_conditional_access_coverage/entra_all_apps_conditional_access_coverage.metadata.json index d76c4e6991..fda451dc8f 100644 --- a/prowler/providers/m365/services/entra/entra_all_apps_conditional_access_coverage/entra_all_apps_conditional_access_coverage.metadata.json +++ b/prowler/providers/m365/services/entra/entra_all_apps_conditional_access_coverage/entra_all_apps_conditional_access_coverage.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "At least one Conditional Access policy is configured to target **all cloud apps**. This ensures comprehensive security coverage and automatic protection for newly onboarded applications without requiring policy updates.", + "Description": "Microsoft Entra **Conditional Access** has at least one policy configured to target **all cloud apps**. This ensures comprehensive security coverage and automatic protection for newly onboarded applications without requiring policy updates.", "Risk": "Without a policy targeting **all cloud apps**, newly integrated applications may not be protected by **Conditional Access**. This creates security gaps where users could access sensitive resources without proper authentication controls.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.metadata.json b/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.metadata.json index 7d4ecbe1d7..4044bef298 100644 --- a/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.metadata.json +++ b/prowler/providers/m365/services/entra/entra_app_registration_no_unused_privileged_permissions/entra_app_registration_no_unused_privileged_permissions.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "App Registration", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "OAuth app registrations with privileged API permissions (High privilege level) that are not being actively used. Usage status is determined by Microsoft Defender for Cloud Apps App Governance.", + "Description": "Microsoft Entra **OAuth app registrations** with privileged API permissions (High privilege level) that are not being actively used. Usage status is determined by Microsoft Defender for Cloud Apps App Governance.", "Risk": "Unused privileged permissions expand the attack surface. If a compromised app has dormant privileged permissions, attackers can exploit them for **privilege escalation**, **unauthorized access** to sensitive data, or **lateral movement** within the environment.", "RelatedUrl": "", "AdditionalURLs": [ @@ -29,7 +29,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/__init__.py b/prowler/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled.metadata.json b/prowler/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled.metadata.json new file mode 100644 index 0000000000..620bc5f634 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_authentication_method_sms_voice_disabled", + "CheckTitle": "SMS and Voice authentication methods are disabled in the tenant", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra tenant's authentication methods policy should have **SMS and Voice** authentication methods disabled. These methods are vulnerable to **SIM-swapping**, **interception**, and **social engineering** attacks, and are deprecated by NIST SP 800-63B as out-of-band authenticators.", + "Risk": "Enabled SMS or Voice authentication allows attackers to bypass MFA through **SIM-swapping** or **SS7 protocol interception**, gaining unauthorized access to accounts. These methods lack cryptographic binding to the device, making them significantly weaker than phishing-resistant alternatives.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-phone-options", + "https://pages.nist.gov/800-63-3/sp800-63b.html" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com/\n2. Go to **Protection** > **Authentication methods** > **Policies**\n3. Select **SMS** and set its status to **Disabled**, then click **Save**\n4. Select **Voice call** and set its status to **Disabled**, then click **Save**\n5. Ensure users have alternative phishing-resistant MFA methods configured (e.g., FIDO2, Microsoft Authenticator)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable SMS and Voice authentication methods and adopt **phishing-resistant** alternatives such as FIDO2 security keys or Microsoft Authenticator. Use Authentication Strengths in Conditional Access policies to enforce only strong MFA methods across the tenant.", + "Url": "https://hub.prowler.com/check/entra_authentication_method_sms_voice_disabled" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled.py b/prowler/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled.py new file mode 100644 index 0000000000..a89de782a0 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled.py @@ -0,0 +1,69 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_authentication_method_sms_voice_disabled(Check): + """ + Ensure that SMS and Voice authentication methods are disabled in Microsoft Entra. + + This check verifies that the tenant's authentication methods policy has both SMS and + Voice methods disabled, as they are vulnerable to SIM-swapping, interception, and + social engineering attacks. NIST SP 800-63B deprecates SMS as an out-of-band + authenticator. + + - PASS: Both SMS and Voice authentication methods are disabled. + - FAIL: SMS and/or Voice authentication methods are enabled. + """ + + def execute(self) -> List[CheckReportM365]: + """Execute the SMS and Voice authentication method check. + + Evaluates the authentication method configurations from the Entra client + and checks whether both SMS and Voice methods are disabled. + + Returns: + A list with a single report containing the result of the check. + """ + findings = [] + configs = entra_client.authentication_method_configurations + + sms_config = configs.get("Sms") + voice_config = configs.get("Voice") + + if sms_config or voice_config: + sms_enabled = sms_config and sms_config.state == "enabled" + voice_enabled = voice_config and voice_config.state == "enabled" + + report = CheckReportM365( + metadata=self.metadata(), + resource=sms_config or voice_config, + resource_name="SMS and Voice Authentication Methods", + resource_id=entra_client.tenant_domain, + ) + + if sms_enabled and voice_enabled: + report.status = "FAIL" + report.status_extended = ( + "SMS and Voice authentication methods are enabled in the tenant." + ) + elif sms_enabled: + report.status = "FAIL" + report.status_extended = ( + "SMS authentication method is enabled in the tenant." + ) + elif voice_enabled: + report.status = "FAIL" + report.status_extended = ( + "Voice authentication method is enabled in the tenant." + ) + else: + report.status = "PASS" + report.status_extended = ( + "SMS and Voice authentication methods are disabled in the tenant." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/__init__.py b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.metadata.json b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.metadata.json new file mode 100644 index 0000000000..fe2753d131 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_break_glass_account_fido2_security_key_registered", + "CheckTitle": "Break glass account has a FIDO2 security key registered for phishing-resistant authentication", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra break glass (emergency access) accounts should have at least one **FIDO2 security key** registered as their authentication method. These accounts are identified as users excluded from all enabled Conditional Access policies.", + "Risk": "Without FIDO2 security keys, break glass accounts rely on weaker authentication methods vulnerable to **phishing, credential theft, and man-in-the-middle attacks**. Compromised emergency access accounts could grant an attacker unrestricted tenant access, bypassing all Conditional Access protections.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-emergency-access", + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-passwordless#fido2-security-keys" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center > Users.\n2. Select the break glass account.\n3. Go to Authentication methods > Add authentication method.\n4. Select FIDO2 Security Key and follow the registration steps.\n5. Store the physical FIDO2 key in a secure location (e.g., physical safe).", + "Terraform": "" + }, + "Recommendation": { + "Text": "Register at least one **FIDO2 security key** for each break glass account. Store the physical keys in a secure, offline location such as a safe. Use phishing-resistant authentication to protect emergency access accounts from credential-based attacks.", + "Url": "https://hub.prowler.com/check/entra_break_glass_account_fido2_security_key_registered" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_emergency_access_exclusion" + ], + "Notes": "Break glass accounts are identified as users excluded from all enabled Conditional Access policies. This check requires the entra_emergency_access_exclusion check to pass first for meaningful results." +} diff --git a/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py new file mode 100644 index 0000000000..de973e9ffa --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered.py @@ -0,0 +1,104 @@ +from collections import Counter + +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_break_glass_account_fido2_security_key_registered(Check): + """Ensure that break glass accounts have FIDO2 security keys registered. + + This check identifies break glass (emergency access) accounts by finding users + excluded from all enabled Conditional Access policies, then verifies each has + at least one FIDO2 security key registered as an authentication method. + + - PASS: The break glass account has a FIDO2 security key (fido2SecurityKey) registered. + - MANUAL: The account has a device-bound passkey but it cannot be confirmed as FIDO2, + or no break glass accounts could be identified. + - FAIL: The break glass account does not have a FIDO2 security key registered. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check for FIDO2 registration on break glass accounts. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + enabled_policies = [ + policy + for policy in entra_client.conditional_access_policies.values() + if policy.state != ConditionalAccessPolicyState.DISABLED + ] + + if not enabled_policies: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Break Glass Accounts", + resource_id="breakGlassAccounts", + ) + report.status = "MANUAL" + report.status_extended = "No enabled Conditional Access policies found. Break glass accounts cannot be identified to verify FIDO2 registration." + findings.append(report) + return findings + + total_policy_count = len(enabled_policies) + + excluded_users_counter = Counter() + for policy in enabled_policies: + user_conditions = policy.conditions.user_conditions + if user_conditions: + for user_id in user_conditions.excluded_users: + excluded_users_counter[user_id] += 1 + + break_glass_user_ids = [ + user_id + for user_id, count in excluded_users_counter.items() + if count == total_policy_count + ] + + if not break_glass_user_ids: + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Break Glass Accounts", + resource_id="breakGlassAccounts", + ) + report.status = "MANUAL" + report.status_extended = "No break glass accounts identified. No users are excluded from all enabled Conditional Access policies." + findings.append(report) + return findings + + for user_id in break_glass_user_ids: + user = entra_client.users.get(user_id) + if not user: + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=user, + resource_name=user.name, + resource_id=user.id, + ) + + auth_methods = set(user.authentication_methods) + has_fido2 = "fido2SecurityKey" in auth_methods + has_passkey_device_bound = "passKeyDeviceBound" in auth_methods + + if has_fido2: + report.status = "PASS" + report.status_extended = f"Break glass account {user.name} has a FIDO2 security key registered." + elif has_passkey_device_bound: + report.status = "MANUAL" + report.status_extended = f"Break glass account {user.name} has a device-bound passkey registered, but it cannot be confirmed whether it is a FIDO2 security key." + else: + report.status = "FAIL" + report.status_extended = f"Break glass account {user.name} does not have a FIDO2 security key registered." + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_app_enforced_restrictions/entra_conditional_access_policy_app_enforced_restrictions.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_app_enforced_restrictions/entra_conditional_access_policy_app_enforced_restrictions.metadata.json index 65f763ab64..8dbf843451 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_app_enforced_restrictions/entra_conditional_access_policy_app_enforced_restrictions.metadata.json +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_app_enforced_restrictions/entra_conditional_access_policy_app_enforced_restrictions.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Conditional Access policy with **application enforced restrictions** limits access to SharePoint, OneDrive, and Exchange content from unmanaged devices.\n\nThis control helps prevent data exfiltration by restricting download, print, and sync capabilities on devices that are not managed by the organization.", + "Description": "Microsoft Entra **Conditional Access** policy with **application enforced restrictions** limits access to SharePoint, OneDrive, and Exchange content from unmanaged devices.\n\nThis control helps prevent data exfiltration by restricting download, print, and sync capabilities on devices that are not managed by the organization.", "Risk": "Without application enforced restrictions, users accessing SharePoint, OneDrive, and Exchange from unmanaged devices can:\n\n- **Download** sensitive files to personal devices\n- **Print** confidential documents\n- **Sync** corporate data to uncontrolled locations\n\nThis increases the risk of data leakage and unauthorized access to sensitive information.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.metadata.json new file mode 100644 index 0000000000..9c3ad12acd --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_approved_client_app_required_for_mobile", + "CheckTitle": "Conditional Access policy enforces approved client apps or app protection for mobile devices", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** policies can require that only **approved client apps** or apps with **app protection policies** are used on iOS and Android devices. This ensures corporate data on mobile platforms is accessed only through managed or protected applications.", + "Risk": "Without requiring approved or protected client apps on mobile platforms, users can access corporate data through **unmanaged applications** that lack security controls. This increases the risk of **data leakage**, unauthorized data sharing, and exposure of sensitive information on personal or compromised mobile devices.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-policy-approved-app-or-app-protection", + "https://learn.microsoft.com/en-us/mem/intune/apps/app-protection-policy" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Select **New policy**.\n4. Under **Users**, include **All users**.\n5. Under **Target resources**, include **All cloud apps**.\n6. Under **Conditions** > **Device platforms**, select **Android** and **iOS**.\n7. Under **Grant**, select **Require app protection policy** (preferred) or **Require approved client app**.\n8. Set the operator to **OR**.\n9. Enable the policy and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce Conditional Access policies requiring app protection policies or approved client apps on iOS and Android devices. Prefer **app protection policies** over approved client apps, as the approved client app grant control is retiring June 30, 2026. Regularly review policies to ensure mobile access is restricted to managed applications.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_approved_client_app_required_for_mobile" + } + }, + "Categories": [ + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The 'Require approved client app' grant control is retiring June 30, 2026. Organizations should migrate to 'Require app protection policy' (compliantApplication)." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.py new file mode 100644 index 0000000000..dd865064c2 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile.py @@ -0,0 +1,113 @@ +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, + GrantControlOperator, +) + + +class entra_conditional_access_policy_approved_client_app_required_for_mobile(Check): + """Check if a Conditional Access policy requires approved client apps or app protection for mobile devices. + + This check ensures that at least one enabled Conditional Access policy + targets iOS and Android platforms and requires approved client apps or + app protection policies. + - PASS: An enabled policy requires approved client apps or app protection for iOS/Android. + - FAIL: No policy restricts mobile app access to approved or protected apps. + """ + + REQUIRED_MOBILE_PLATFORMS = {"android", "ios"} + MOBILE_APP_GRANT_CONTROLS = { + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + } + + @staticmethod + def _normalize_platform(platform: object) -> str: + normalized_platform = getattr(platform, "value", platform) + return ( + normalized_platform.lower() if isinstance(normalized_platform, str) else "" + ) + + def execute(self) -> list[CheckReportM365]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not policy.conditions.platform_conditions: + continue + + included_platforms = { + normalized_platform + for normalized_platform in map( + self._normalize_platform, + policy.conditions.platform_conditions.include_platforms, + ) + if normalized_platform + } + excluded_platforms = { + normalized_platform + for normalized_platform in map( + self._normalize_platform, + policy.conditions.platform_conditions.exclude_platforms, + ) + if normalized_platform + } + + targets_mobile_platforms = ( + "all" in included_platforms + or self.REQUIRED_MOBILE_PLATFORMS.issubset(included_platforms) + ) and not ( + "all" in excluded_platforms + or self.REQUIRED_MOBILE_PLATFORMS.intersection(excluded_platforms) + ) + if not targets_mobile_platforms: + continue + + built_in_controls = set(policy.grant_controls.built_in_controls) + has_mobile_app_control = bool( + self.MOBILE_APP_GRANT_CONTROLS.intersection(built_in_controls) + ) + if not has_mobile_app_control: + continue + + if ( + policy.grant_controls.operator == GrantControlOperator.OR + and not built_in_controls.issubset(self.MOBILE_APP_GRANT_CONTROLS) + ): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports the requirement of approved client apps or app protection for mobile devices but does not enforce it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' requires approved client apps or app protection for mobile devices." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk.metadata.json new file mode 100644 index 0000000000..439c7803f7 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_block_elevated_insider_risk", + "CheckTitle": "Conditional Access Policy blocks access for users with elevated insider risk", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "This check verifies that at least one **enabled** Conditional Access policy **blocks access** to all cloud applications for users flagged with an **elevated insider risk** level by Microsoft Purview Insider Risk Management and Adaptive Protection.", + "Risk": "Without blocking elevated insider risk users, compromised or malicious insiders retain **full access** to cloud applications. This enables data exfiltration, unauthorized modifications, and lateral movement, directly impacting **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/purview/insider-risk-management-adaptive-protection", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/how-to-policy-insider-risk" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the Microsoft Entra admin center, go to Protection > Conditional Access > Policies.\n2. Click New policy.\n3. Under Users, select Include > All users.\n4. Under Target resources, select Include > All cloud apps.\n5. Under Conditions > Insider risk, select Configure > Yes, then check Elevated.\n6. Under Grant, select Block access.\n7. Set Enable policy to On and click Create.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure **Adaptive Protection** in Microsoft Purview to classify insider risk tiers, then create a Conditional Access policy that **blocks access** to all cloud apps for users with **elevated** risk. Only exclude dedicated break-glass accounts.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_block_elevated_insider_risk" + } + }, + "Categories": [ + "identity-access", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check requires Microsoft 365 E5 with Microsoft Purview Insider Risk Management and Adaptive Protection configured. The insiderRiskLevels condition in Conditional Access evaluates the insider risk level assigned by Purview Adaptive Protection." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk.py new file mode 100644 index 0000000000..2d96c2d289 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk.py @@ -0,0 +1,93 @@ +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, + InsiderRiskLevel, +) + + +class entra_conditional_access_policy_block_elevated_insider_risk(Check): + """Check if a Conditional Access policy blocks all cloud app access for elevated insider risk users. + + This check verifies that at least one enabled Conditional Access policy + blocks access to all cloud applications for users with an elevated insider + risk level, as determined by Microsoft Purview Insider Risk Management + and Adaptive Protection. + + - PASS: An enabled CA policy blocks all cloud app access for elevated insider risk users. + - FAIL: No enabled CA policy blocks broad cloud app access based on insider risk signals. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy blocks access for users with elevated insider risk." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not policy.conditions.application_conditions: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + if ( + ConditionalAccessGrantControl.BLOCK + not in policy.grant_controls.built_in_controls + ): + continue + + if policy.conditions.insider_risk_levels is None: + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "FAIL" + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status_extended = f"Conditional Access Policy {policy.display_name} is configured in report-only mode to block all cloud apps and Microsoft Purview Adaptive Protection is not providing insider risk signals." + else: + report.status_extended = f"Conditional Access Policy {policy.display_name} is configured to block all cloud apps and Microsoft Purview Adaptive Protection is not providing insider risk signals." + continue + + if policy.conditions.insider_risk_levels != InsiderRiskLevel.ELEVATED: + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy {policy.display_name} reports blocking all cloud apps for elevated insider risk users but does not enforce it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} blocks access to all cloud apps for users with elevated insider risk." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk.metadata.json new file mode 100644 index 0000000000..03f86ba8c0 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_block_o365_elevated_insider_risk", + "CheckTitle": "Conditional Access policy blocks Office 365 access for users with elevated insider risk", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "A Conditional Access policy configured to **block** Office 365 access for users flagged with **elevated insider risk** by Microsoft Purview Adaptive Protection.\n\nThis control uses behavioral signals to restrict access when a user's risk level indicates potential insider threat activity.", + "Risk": "Without a policy blocking Office 365 for elevated insider risk users, compromised or malicious insiders can continue accessing email, SharePoint, OneDrive, and Teams.\n\nThis may lead to **data exfiltration**, unauthorized sharing of sensitive information, or tampering with critical business communications.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/purview/insider-risk-management-adaptive-protection", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/how-to-policy-insider-risk" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, select **All users**.\n5. Under **Target resources** > **Cloud apps**, select **Office 365**.\n6. Under **Conditions** > **Insider risk**, select **Elevated**.\n7. Under **Grant**, select **Block access**.\n8. Set the policy to **On** and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a Conditional Access policy to **block** Office 365 access for users with **elevated insider risk** levels. This leverages Microsoft Purview Adaptive Protection signals to dynamically restrict access based on user behavior analysis.\n\nEnsure Insider Risk Management and Adaptive Protection are configured in Microsoft Purview as prerequisites.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_block_o365_elevated_insider_risk" + } + }, + "Categories": [ + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requires Microsoft 365 E5 or E5 Compliance license with Insider Risk Management configured and Adaptive Protection enabled in Microsoft Purview." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk.py new file mode 100644 index 0000000000..8dc35527b7 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk.py @@ -0,0 +1,94 @@ +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, + InsiderRiskLevel, +) + +OFFICE365_APP_ID = "Office365" + + +class entra_conditional_access_policy_block_o365_elevated_insider_risk(Check): + """Check if a Conditional Access policy blocks Office 365 access for elevated insider risk users. + + This check verifies that at least one enabled Conditional Access policy blocks + access to Office 365 applications for users with elevated insider risk levels, + as determined by Microsoft Purview Adaptive Protection. + + - PASS: At least one enabled policy blocks Office 365 access for users with elevated insider risk. + - FAIL: No enabled policy blocks Office 365 access based on insider risk signals. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check for insider risk blocking in Conditional Access policies. + + Returns: + list[CheckReportM365]: A list containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy blocks Office 365 access for users with elevated insider risk." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + OFFICE365_APP_ID + not in policy.conditions.application_conditions.included_applications + and "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + if ( + ConditionalAccessGrantControl.BLOCK + not in policy.grant_controls.built_in_controls + ): + continue + + # Policy targets all users, O365/All apps, and blocks access. + # Now check if Adaptive Protection is providing insider risk signals. + if policy.conditions.insider_risk_levels is None: + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "FAIL" + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status_extended = f"Conditional Access Policy {policy.display_name} is configured in report-only mode to block Office 365 and Microsoft Purview Adaptive Protection is not providing insider risk signals." + else: + report.status_extended = f"Conditional Access Policy {policy.display_name} is configured to block Office 365 and Microsoft Purview Adaptive Protection is not providing insider risk signals." + continue + + if policy.conditions.insider_risk_levels != InsiderRiskLevel.ELEVATED: + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy {policy.display_name} reports blocking Office 365 for elevated insider risk users but does not enforce it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} blocks Office 365 access for users with elevated insider risk." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.metadata.json new file mode 100644 index 0000000000..1765e524a3 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required", + "CheckTitle": "Conditional Access requires compliant device OR hybrid joined device OR MFA for admins or all users", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** policy enforces one of the following grant controls for admin roles or all users across all cloud apps: - 'Require device to be marked as compliant' - 'Require Microsoft Entra hybrid joined device' - 'Require multifactor authentication' This ensures that access is provided only under strong authentication or trusted device conditions.", + "Risk": "If this policy is not implemented, attackers with compromised credentials may gain access from unmanaged devices or without strong authentication, increasing the likelihood of **unauthorized access and data breaches**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-compliant-device" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com.\n2. Go to Protection > Conditional Access > Policies and create or edit a policy.\n3. Under Users, include All users or administrative roles.\n4. Under Target resources, include All cloud apps.\n5. Under Grant, select Grant access and enable these controls: Require multifactor authentication, Require device to be marked as compliant, and Require Microsoft Entra hybrid joined device.\n6. Set Grant operator to Require one of the selected controls.\n7. Start in Report-only mode for validation and then switch to On.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce a Conditional Access baseline where admins or all users must satisfy at least one strong control: compliant device, hybrid joined device, or MFA.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_managed_device_required_for_authentication" + ], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.py new file mode 100644 index 0000000000..f872fadac3 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.py @@ -0,0 +1,78 @@ +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 ( + AdminRoles, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + GrantControlOperator, +) + +REQUIRED_GRANT_CONTROLS = { + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ConditionalAccessGrantControl.DOMAIN_JOINED_DEVICE, +} +ADMIN_ROLE_IDS = {role.value for role in AdminRoles} + + +class entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required( + Check +): + """Check that CA enforces compliant or hybrid joined device or MFA for admins/all users.""" + + def _targets_admins_or_all_users(self, policy) -> bool: + if "All" in policy.conditions.user_conditions.included_users: + return True + + included_roles = set(policy.conditions.user_conditions.included_roles) + return bool(ADMIN_ROLE_IDS.intersection(included_roles)) + + def execute(self) -> list[CheckReportM365]: + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy requires compliant device, hybrid joined device, or MFA for admin roles or all users across all cloud apps." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if not self._targets_admins_or_all_users(policy): + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + policy_grant_controls = set(policy.grant_controls.built_in_controls) + if not REQUIRED_GRANT_CONTROLS.issubset(policy_grant_controls): + continue + + if policy.grant_controls.operator != GrantControlOperator.OR: + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy {policy.display_name} reports compliant device, hybrid joined device, or MFA for admin roles or all users but does not enforce it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} enforces compliant device, hybrid joined device, or MFA for admin roles or all users across all cloud apps." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.metadata.json new file mode 100644 index 0000000000..256b1f88c6 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_device_code_flow_blocked", + "CheckTitle": "Conditional Access policy blocks device code flow to prevent phishing attacks", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Conditional Access policies restrict the **device code authentication flow**, which is commonly abused in phishing campaigns to hijack user sessions. A policy targeting `deviceCodeFlow` in authentication flow conditions with a block grant control prevents this attack vector.", + "Risk": "Device code flow is heavily exploited in phishing attacks such as **Storm-2372**, where attackers trick users into entering device codes on legitimate Microsoft login pages. Without a blocking policy, attackers can obtain tokens and gain persistent access to organizational resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-conditions#authentication-flows", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-authentication-flows" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center at https://entra.microsoft.com.\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, include **All users** and exclude break-glass accounts.\n5. Under **Target resources**, include **All cloud apps**.\n6. Under **Conditions** > **Authentication flows**, select **Device code flow**.\n7. Under **Grant**, select **Block access**.\n8. Set the policy to **On** and click **Create**.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Block device code flow via Conditional Access to mitigate phishing attacks that abuse this authentication method. Exclude only break-glass accounts and legitimate service accounts that require device code flow. Regularly review exceptions to minimize the attack surface.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_device_code_flow_blocked" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_legacy_authentication_blocked" + ], + "Notes": "The authenticationFlows condition in Conditional Access may require the beta Graph API endpoint and the Prefer: include-unknown-enum-members header." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.py new file mode 100644 index 0000000000..0b0b677a62 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked.py @@ -0,0 +1,78 @@ +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, + TransferMethod, +) + + +class entra_conditional_access_policy_device_code_flow_blocked(Check): + """Check if at least one Conditional Access policy blocks device code flow. + + This check ensures that at least one enabled Conditional Access policy + targets the device code authentication flow and blocks access, protecting + against phishing attacks that abuse this flow (e.g., Storm-2372). + + - PASS: An enabled Conditional Access policy blocks device code flow. + - FAIL: No Conditional Access policy restricts device code flow. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check to verify device code flow is blocked by a Conditional Access policy. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy blocks device code flow." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + "All" + not in policy.conditions.application_conditions.included_applications + ): + continue + + if not policy.conditions.authentication_flows: + continue + + if ( + TransferMethod.DEVICE_CODE_FLOW + not in policy.conditions.authentication_flows.transfer_methods + ): + continue + + if ( + ConditionalAccessGrantControl.BLOCK + in policy.grant_controls.built_in_controls + ): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' reports device code flow but does not block it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy '{policy.display_name}' blocks device code flow." + break + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.metadata.json new file mode 100644 index 0000000000..fe2985fa09 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_device_registration_mfa_required", + "CheckTitle": "Conditional Access policies enforce MFA for device registration", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** policies can require **multifactor authentication (MFA)** for **device registration** operations.\n\nThis control ensures users must complete MFA before **registering or joining devices** to the directory, reducing the likelihood that compromised credentials can be used to register rogue devices.", + "Risk": "Without MFA for device registration, attackers with stolen credentials could register unauthorized devices into the directory, gain persistence, and bypass compliance-based Conditional Access protections that rely on trusted device state.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-device-registration", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps#user-actions" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center (https://entra.microsoft.com).\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Click **New policy**.\n4. Under **Users**, include **All users**.\n5. Under **Target resources**, select **User actions** and check **Register or join devices**.\n6. Under **Grant**, select **Grant access** and require **multifactor authentication**.\n7. Set the policy to **Report-only** until validated, then enable it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce **MFA** through **Conditional Access** for **device registration** so users must verify identity before registering or joining devices to the directory.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_device_registration_mfa_required" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_managed_device_required_for_mfa_registration", + "entra_intune_enrollment_sign_in_frequency_every_time" + ], + "Notes": "Requires Entra ID P1 or later license." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.py new file mode 100644 index 0000000000..d137717584 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required.py @@ -0,0 +1,75 @@ +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, + UserAction, +) + + +class entra_conditional_access_policy_device_registration_mfa_required(Check): + """Ensure MFA is required for device registration.""" + + def execute(self) -> list[CheckReportM365]: + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No Conditional Access Policy requires MFA for device registration." + ) + + reporting_policy = None + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if ( + UserAction.REGISTER_DEVICE + not in policy.conditions.application_conditions.included_user_actions + ): + continue + + if ( + ConditionalAccessGrantControl.MFA + not in policy.grant_controls.built_in_controls + ): + continue + + if policy.state == ConditionalAccessPolicyState.ENABLED: + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "PASS" + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' enforces MFA " + "for device registration." + ) + break + + if ( + policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING + and reporting_policy is None + ): + reporting_policy = policy + + if report.status == "FAIL" and reporting_policy: + report.status_extended = ( + f"Conditional Access Policy '{reporting_policy.display_name}' reports " + "MFA for device registration but does not enforce it." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required.metadata.json new file mode 100644 index 0000000000..80df69b01b --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_mdm_compliant_device_required", + "CheckTitle": "Conditional Access policy requires an MDM-compliant device for all cloud app access", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "An enabled Conditional Access policy enforces **Require device to be marked as compliant** for **all users** across **all cloud apps** with no application exclusions. This check also verifies that Intune has assigned compliance policies and at least one currently reported **compliant MDM-managed device**. If Graph exposes tenant settings, it also checks **secure-by-default** evaluation.", + "Risk": "Without a mandatory MDM-backed compliant device requirement, users can access cloud resources from **unmanaged or non-compliant devices**. If Intune compliance policies are missing, unassigned, or devices without policy assignment can remain compliant, Conditional Access can create a **false sense of protection** and increase the risk of **credential theft** and **data exfiltration**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-grant", + "https://learn.microsoft.com/en-us/intune/intune-service/protect/device-compliance-get-started", + "https://learn.microsoft.com/en-us/intune/intune-service/protect/conditional-access", + "https://learn.microsoft.com/en-us/windows/client-management/mdm-overview", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-compliant-device" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Intune admin center https://intune.microsoft.com with a role that can manage device compliance and enrollment.\n2. Confirm the tenant has Microsoft Intune enabled and the target users have the required Microsoft Entra ID P1 or P2 and Intune licensing needed for Conditional Access with compliance.\n3. In Intune, go to **Devices** > **Compliance** > **Policies** and select **Create policy**.\n4. Choose the target platform, configure the compliance settings your organization requires, review the configuration, and save the policy.\n5. Open the new compliance policy, go to **Assignments**, and assign it to the users or devices that must satisfy the MDM-compliant device requirement.\n6. In Intune, go to **Endpoint security** > **Device compliance** > **Compliance policy settings**.\n7. Set **Mark devices with no compliance policy assigned as** to **Not compliant** so devices without policy assignment cannot be treated as compliant.\n8. Configure or confirm an enrollment path for at least one supported platform. For a lab, the simplest path is usually a Windows device enrolled through **Company Portal**.\n9. Enroll at least one device into Intune MDM, complete registration, and allow the device to sync.\n10. In Intune, verify the enrolled device appears under **Devices** as managed through an MDM/Intune management channel and that its compliance state becomes **Compliant**.\n11. Sign in to the Microsoft Entra admin center https://entra.microsoft.com.\n12. Go to **Protection** > **Conditional Access** > **Policies** and select **New policy**.\n13. Under **Users**, include **All users**. This check does not evaluate **excluded users**, but it does require **All users** to be included.\n14. Under **Target resources**, include **All cloud apps** and do not exclude applications.\n15. Under **Grant**, select **Grant access** and enable **Require device to be marked as compliant**.\n16. If you add extra grant controls such as MFA, configure **Require all the selected controls** and do not weaken the policy with **Require one of the selected controls**.\n17. Set **Enable policy** to **On** and create the policy after validating impact.\n18. If Prowler uses an application identity to read Intune, grant and consent the Microsoft Graph permissions **DeviceManagementServiceConfig.Read.All**, **DeviceManagementConfiguration.Read.All**, and **DeviceManagementManagedDevices.Read.All** so the Intune portion of this check can be verified.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce a Conditional Access policy requiring all users to access cloud applications only from **MDM-compliant devices**. Back that policy with Microsoft Intune device compliance policies that are actually assigned, configure Intune so devices without a compliance policy assignment are treated as **Not compliant**, and verify that at least one device is currently enrolled and reporting a **compliant** state through an MDM/Intune management channel. If Microsoft Graph exposes Intune tenant settings, also confirm the tenant reports **secure-by-default** compliance evaluation.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_mdm_compliant_device_required" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_managed_device_required_for_authentication" + ], + "Notes": "For this check, **MDM-compliant device** means a Conditional Access policy requires **device marked as compliant**, Microsoft Intune is configured with at least one assigned device compliance policy, and Intune currently reports at least one **compliant** device under an MDM/Intune management channel. This check evaluates Conditional Access plus Intune configuration and current Intune device evidence together. It requires **All users** to be included, but it does **not** evaluate the **excluded users** field. It does require **All cloud apps** to be included and it fails when applications are excluded. It also fails when the compliant-device control is weakened by an **OR** grant operator with alternative controls such as MFA or authentication strength. When Microsoft Graph exposes **deviceManagement/settings.secureByDefault**, the check validates that setting and fails if devices without policy assignment can remain compliant. Some tenants can return **settings = null** in both Graph **v1.0** and **beta** even when Intune is active; in that case, the check does not fail solely because secure-by-default cannot be verified. The Intune portion of the check still validates compliance policies, compliance policy assignments, and the existence of at least one currently reported compliant MDM-managed device; a newly configured tenant with no compliant managed devices yet will still fail. If the identity used by Prowler cannot read Intune compliance policies, assignments, or managed devices, the result can become **MANUAL** instead of **PASS**." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required.py new file mode 100644 index 0000000000..ec11207554 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required.py @@ -0,0 +1,198 @@ +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, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + GrantControlOperator, +) +from prowler.providers.m365.services.intune.intune_client import intune_client +from prowler.providers.m365.services.intune.intune_service import Intune + + +class entra_conditional_access_policy_mdm_compliant_device_required(Check): + """Ensure a Conditional Access policy requires an MDM-compliant device for all cloud app access. + + This check verifies that at least one enabled Conditional Access policy enforces + the compliant device grant control for all cloud applications and that Microsoft + Intune compliance prerequisites are configured to make that MDM requirement + effective. + + - PASS: An enabled policy requires a compliant device for all cloud app access. + - FAIL: No policy mandates device compliance, or Intune prerequisites are not configured. + - MANUAL: Intune prerequisites cannot be verified due to missing visibility. + """ + + def execute(self) -> list[CheckReportM365]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + + reporting_policy = None + + for policy in entra_client.conditional_access_policies.values(): + if not self._is_candidate_policy(policy): + continue + + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + if reporting_policy is None: + reporting_policy = policy + continue + + report = self._build_policy_report(policy) + + verification_error = getattr(intune_client, "verification_error", None) + if verification_error: + report.status = "MANUAL" + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' requires an " + "MDM-compliant device for all cloud app access, but Microsoft " + f"Intune MDM compliance prerequisites could not be verified. {verification_error}" + ) + findings.append(report) + return findings + + compliance_policies = ( + getattr(intune_client, "compliance_policies", []) or [] + ) + if not compliance_policies: + report.status = "FAIL" + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' requires an " + "MDM-compliant device for all cloud app access, but no Microsoft " + "Intune device compliance policies are configured." + ) + findings.append(report) + return findings + + assigned_policies = [ + compliance_policy + for compliance_policy in compliance_policies + if getattr(compliance_policy, "assignment_count", 0) > 0 + ] + if not assigned_policies: + report.status = "FAIL" + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' requires an " + "MDM-compliant device for all cloud app access, but no Microsoft " + "Intune device compliance policy is assigned." + ) + findings.append(report) + return findings + + settings = getattr(intune_client, "settings", None) + secure_by_default = getattr(settings, "secure_by_default", None) + if secure_by_default is False: + report.status = "FAIL" + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' requires an " + "MDM-compliant device for all cloud app access, but Microsoft " + "Intune allows devices without an assigned compliance policy to " + "remain compliant." + ) + findings.append(report) + return findings + + managed_devices = getattr(intune_client, "managed_devices", []) or [] + mdm_compliant_devices = [ + managed_device + for managed_device in managed_devices + if getattr(managed_device, "compliance_state", "") == "compliant" + and Intune.is_mdm_managed_device( + getattr(managed_device, "management_agent", "") + ) + ] + if not mdm_compliant_devices: + report.status = "FAIL" + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' requires an " + "MDM-compliant device for all cloud app access, but Microsoft " + "Intune does not currently report any compliant MDM-managed devices." + ) + findings.append(report) + return findings + + report.status = "PASS" + if secure_by_default is None: + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' requires an " + "MDM-compliant device for all cloud app access, and Microsoft Intune " + "is configured with assigned compliance policies and at least one " + "compliant MDM-managed device. Microsoft Graph did not return " + "device management settings, so secure-by-default compliance " + "evaluation could not be verified." + ) + else: + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' requires an " + "MDM-compliant device for all cloud app access, and Microsoft Intune " + "is configured with assigned compliance policies, secure-by-default " + "compliance evaluation, and at least one compliant MDM-managed device." + ) + findings.append(report) + return findings + + if reporting_policy is not None: + report = self._build_policy_report(reporting_policy) + report.status = "FAIL" + report.status_extended = ( + f"Conditional Access Policy '{reporting_policy.display_name}' reports " + "the requirement of an MDM-compliant device for all cloud app access " + "but does not enforce it." + ) + + findings.append(report) + return findings + + def _build_policy_report(self, policy: ConditionalAccessPolicy) -> CheckReportM365: + return CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + + @staticmethod + def _is_candidate_policy(policy: ConditionalAccessPolicy) -> bool: + if policy.state == ConditionalAccessPolicyState.DISABLED: + return False + + application_conditions = policy.conditions.application_conditions + user_conditions = policy.conditions.user_conditions + if not application_conditions or not user_conditions: + return False + + if "All" not in user_conditions.included_users: + return False + + if "All" not in application_conditions.included_applications: + return False + + if application_conditions.excluded_applications != []: + return False + + if ( + ConditionalAccessGrantControl.COMPLIANT_DEVICE + not in policy.grant_controls.built_in_controls + ): + return False + + if policy.grant_controls.operator == GrantControlOperator.OR and ( + len(policy.grant_controls.built_in_controls) > 1 + or policy.grant_controls.authentication_strength is not None + ): + return False + + return True diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api.metadata.json index f07368a2a6..acbd90a543 100644 --- a/prowler/providers/m365/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api.metadata.json +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_require_mfa_for_management_api/entra_conditional_access_policy_require_mfa_for_management_api.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "This check verifies that at least one **enabled** Conditional Access policy requires **multifactor authentication** for the **Windows Azure Service Management API**, covering Azure Portal, CLI, PowerShell, and IaC tools.", + "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **enabled** policy that requires **multifactor authentication** for the **Windows Azure Service Management API**, covering Azure Portal, CLI, PowerShell, and IaC tools.", "Risk": "Without MFA on Azure management endpoints, compromised credentials allow **control-plane access**. Attackers can modify configurations, create or delete resources, extract secrets, and pivot laterally, compromising confidentiality, integrity, and availability.", "RelatedUrl": "", "AdditionalURLs": [ @@ -29,7 +29,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [ diff --git a/prowler/providers/m365/services/entra/entra_default_app_management_policy_enabled/entra_default_app_management_policy_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_default_app_management_policy_enabled/entra_default_app_management_policy_enabled.metadata.json index 07919b62e0..ab3e1f303e 100644 --- a/prowler/providers/m365/services/entra/entra_default_app_management_policy_enabled/entra_default_app_management_policy_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_default_app_management_policy_enabled/entra_default_app_management_policy_enabled.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Authorization Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "This check verifies that the **default app management policy** in Microsoft Entra ID has the required **credential restrictions** configured for applications: **block password addition**, **restrict max password lifetime**, **block custom passwords**, and **restrict max certificate lifetime**.", + "Description": "Microsoft Entra ID **default app management policy** is verified to have the required **credential restrictions** configured for applications: **block password addition**, **restrict max password lifetime**, **block custom passwords**, and **restrict max certificate lifetime**.", "Risk": "Without the required credential restrictions, applications and service principals can use **insecure credential configurations**, including **long-lived secrets**, **custom passwords**, or **unrestricted certificates**, increasing the risk of **credential compromise** and **unauthorized access**.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.metadata.json b/prowler/providers/m365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.metadata.json index fc3bdf204f..9177223050 100644 --- a/prowler/providers/m365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.metadata.json +++ b/prowler/providers/m365/services/entra/entra_dynamic_group_for_guests_created/entra_dynamic_group_for_guests_created.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "entra_dynamic_group_for_guests_created", - "CheckTitle": "Ensure a dynamic group for guest users is created.", + "CheckTitle": "A dynamic membership group for guest users exists", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Group Settings", + "Severity": "low", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure that a dynamic group is created for guest users in Microsoft Entra to enforce conditional access policies and security controls automatically.", - "Risk": "Without a dynamic group for guest users, administrators may need to manually manage access controls, leading to potential security gaps and inconsistent policy enforcement.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/users/groups-create-rule", + "Description": "Microsoft Entra **groups** are evaluated for **dynamic membership** that includes only users with `userType -eq \"Guest\"`.\n\nThe finding indicates whether a guest-targeted dynamic group exists to centrally scope policies and governance.", + "Risk": "Without a dedicated dynamic guest group, guests may evade consistent **Conditional Access** and least-privilege controls. This threatens **confidentiality** via excess data access, weakens **integrity** through unauthorized changes, and leaves stale external accounts that enable lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/users/groups-create-rule" + ], "Remediation": { "Code": { "CLI": "New-MgGroup -DisplayName 'Dynamic Guest Users' -MailNickname 'DynGuestUsers' -MailEnabled $false -SecurityEnabled $true -GroupTypes 'DynamicMembership' -MembershipRule '(user.userType -eq \"Guest\")' -MembershipRuleProcessingState 'On'", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Click to expand Identity > Groups and select All groups. 3. Select 'New group' and configure: Group type: Security, Membership type: Dynamic User. 4. Add dynamic query with rule: (user.userType -eq 'Guest'). 5. Click Save.", - "Terraform": "" + "Other": "1. Sign in to Microsoft Entra admin center (https://entra.microsoft.com/)\n2. Go to Identity > Groups > All groups > New group\n3. Set Group type: Security; Membership type: Dynamic User\n4. Click Add dynamic query and set the rule: user.userType -eq \"Guest\"; click Save\n5. Click Create", + "Terraform": "```hcl\nresource \"azuread_group\" \"example\" {\n display_name = \"\"\n security_enabled = true\n\n dynamic_membership {\n enabled = true # critical: enables dynamic membership\n rule = \"user.userType -eq \\\"Guest\\\"\" # critical: includes only guest users\n }\n}\n```" }, "Recommendation": { - "Text": "Create a dynamic group for guest users to automate policy enforcement and access control.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/users/groups-create-rule" + "Text": "Establish a **dynamic group** limited to users with `userType -eq \"Guest\"` and use it to scope **Conditional Access**, least-privilege roles, and access reviews.\n\nSegment guests by risk into separate groups, enforce lifecycle policies, and regularly audit membership and policy coverage.", + "Url": "https://hub.prowler.com/check/entra_dynamic_group_for_guests_created" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.metadata.json b/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.metadata.json index 83c25cf6dc..cd7efd39e3 100644 --- a/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.metadata.json +++ b/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "This check verifies that at least one **emergency access** (break glass) account or group is excluded from all **Conditional Access policies**. Emergency access accounts provide a fallback mechanism when normal administrative access is blocked due to misconfigured policies.", + "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **emergency access** (break glass) account or group excluded from all policies. Emergency access accounts provide a fallback mechanism when normal administrative access is blocked due to misconfigured policies.", "Risk": "Without emergency access accounts excluded from Conditional Access policies, a misconfiguration could lock out all administrators from the tenant. This creates a **critical availability risk** where legitimate administrators cannot access or remediate issues in the environment.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.py b/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.py index 1104380292..45f9bda05d 100644 --- a/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.py +++ b/prowler/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion.py @@ -95,10 +95,19 @@ class entra_emergency_access_exclusion(Check): report.status = "PASS" exclusion_details = [] if users_excluded_from_all: - exclusion_details.append(f"{len(users_excluded_from_all)} user(s)") + user_names = [] + for user_id in users_excluded_from_all: + user = entra_client.users.get(user_id) + user_names.append(user.name if user else user_id) + exclusion_details.append(f"user(s): {', '.join(user_names)}") if groups_excluded_from_all: - exclusion_details.append(f"{len(groups_excluded_from_all)} group(s)") - report.status_extended = f"{' and '.join(exclusion_details)} excluded as emergency access across all {total_policy_count} enabled Conditional Access policies." + group_names = [] + groups_by_id = {g.id: g for g in entra_client.groups} + for group_id in groups_excluded_from_all: + group = groups_by_id.get(group_id) + group_names.append(group.name if group else group_id) + exclusion_details.append(f"group(s): {', '.join(group_names)}") + report.status_extended = f"Emergency access {' and '.join(exclusion_details)} excluded from all {total_policy_count} enabled Conditional Access policies." else: report.status = "FAIL" report.status_extended = f"No user or group is excluded as emergency access from all {total_policy_count} enabled Conditional Access policies." diff --git a/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.metadata.json index b9b1045841..2aa589f9c6 100644 --- a/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "entra_identity_protection_sign_in_risk_enabled", - "CheckTitle": "Ensure that Identity Protection sign-in risk policies are enabled", + "CheckTitle": "At least one Conditional Access Identity Protection sign-in risk policy protects against high and medium risk sign-ins", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Conditional Access Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that Identity Protection sign-in risk policies are enabled to detect and respond to suspicious high and medium risk login attempts in real time.", - "Risk": "Without Identity Protection sign-in risk policies enabled, suspicious sign-in attempts may go unnoticed, allowing attackers to access accounts using stolen or compromised credentials. This increases the risk of unauthorized access, data breaches, and privilege escalation.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "Description": "Microsoft Entra **Conditional Access** has a sign-in risk-based Identity Protection policy that targets `All users` and `All cloud apps`, evaluates `Medium` and `High` sign-in risk, requires **MFA**, sets `sign-in frequency: every time`, and is actively enforced *not report-only*.", + "Risk": "Without this policy, risky authentications using stolen or replayed credentials may proceed without step-up verification, enabling account takeover. Attackers can establish persistent sessions, exfiltrate data, change configurations, and move laterally-eroding confidentiality and integrity and potentially impacting availability through privilege abuse.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "https://azure.microsofts.workers.dev/en-us/entra/identity/authentication/tutorial-risk-based-sspr-mfa", + "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "New-MgIdentityConditionalAccessPolicy -BodyParameter @{displayName='';state='enabled';conditions=@{users=@{includeUsers=@('All')};applications=@{includeApplications=@('All')};signInRiskLevels=@('medium','high')};grantControls=@{operator='OR';builtInControls=@('mfa')};sessionControls=@{signInFrequency=@{isEnabled=$true;frequencyInterval='everyTime'}}}", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. 4. Set the following conditions within the policy. Under Users or workload identities choose All users. Under Cloud apps or actions choose All cloud apps. Under Conditions choose Sign-in risk then Yes and check the risk level boxes High and Medium. Under Access Controls select Grant then in the right pane click Grant access then select Require multifactor authentication. Under Session select Sign-in Frequency and set to Every time. Click Select. 5. Under Enable policy set it to Report Only until the organization is ready to enable it. 6. Click Create.", - "Terraform": "" + "Other": "1. Sign in to Microsoft Entra admin center (entra.microsoft.com)\n2. Go to Entra ID > Protection > Conditional Access > Policies > New policy\n3. Users: select All users\n4. Target resources: select All resources (All cloud apps)\n5. Conditions > Sign-in risk: set to Yes, select Medium and High\n6. Grant > Grant access: select Require multifactor authentication\n7. Session > Sign-in frequency: set to Every time\n8. Enable policy: On\n9. Create the policy", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\" # Critical: enforce policy\n\n conditions {\n users {\n include_users = [\"All\"] # Critical: apply to all users\n }\n applications {\n include_applications = [\"All\"] # Critical: apply to all apps\n }\n sign_in_risk_levels = [\"medium\", \"high\"] # Critical: protect Medium and High sign-in risks\n client_app_types = [\"all\"]\n }\n\n grant_controls {\n operator = \"OR\"\n built_in_controls = [\"mfa\"] # Critical: require MFA\n }\n\n session_controls {\n sign_in_frequency_interval = \"everyTime\" # Critical: require reauth every time\n }\n}\n```" }, "Recommendation": { - "Text": "Enable Identity Protection sign-in risk policies to detect and respond to suspicious login attempts in real time. Configure Conditional Access to require MFA for risky sign-ins and ensure all users are enrolled in MFA to prevent account lockouts. Regularly review sign-in risk reports to identify and mitigate potential security threats.", - "Url": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies" + "Text": "Adopt a **risk-based Conditional Access** policy for sign-in risk that applies broadly and enforces **MFA** with `every-time` reauthentication for `Medium` and `High` risk. Align with **Zero Trust** and **least privilege**: ensure MFA enrollment, exclude emergency accounts, validate in report-only, then enforce and regularly review risky sign-in reports.", + "Url": "https://hub.prowler.com/check/entra_identity_protection_sign_in_risk_enabled" } }, "Categories": [ + "identity-access", "e5" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.metadata.json index 736a2d7240..71aceb3c2e 100644 --- a/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "entra_identity_protection_user_risk_enabled", - "CheckTitle": "Ensure that Identity Protection user risk policies are enabled", + "CheckTitle": "At least one Conditional Access policy enforces Identity Protection user risk for high-risk users", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Conditional Access Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that Identity Protection user risk policies are enabled to detect and respond to high risk potential account compromises.", - "Risk": "Without Identity Protection user risk policies enabled, compromised accounts may go undetected, allowing attackers to exploit breached credentials and gain unauthorized access. The absence of automated responses to user risk levels increases the likelihood of security incidents, such as data breaches or privilege escalation.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "Description": "Microsoft Entra **Conditional Access** has a **user risk-based policy** that targets `All` users and `All` applications, evaluates `High` user risk, and actively enforces controls requiring both **multifactor authentication** and a **secure password change** with an `AND` condition.", + "Risk": "Without an active `High` user-risk policy that forces **MFA** and secure password reset, compromised identities can persist, enabling data exfiltration, tampering, and privilege escalation. Report-only mode or narrow scope leaves gaps, undermining confidentiality and integrity across resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-risk-based-sspr-mfa?WT.mc_id=M365-MVP-6771", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. 4. Set the following conditions within the policy: Under Users or workload identities choose All users. Under Cloud apps or actions choose All cloud apps. Under Conditions choose User risk then Yes and select the user risk level High. Under Access Controls select Grant then in the right pane click Grant access then select Require multifactor authentication and Require password change. Under Session ensure Sign-in frequency is set to Every time. Click Select. 5. Under Enable policy set it to Report Only until the organization is ready to enable it. 6. Click Create.", - "Terraform": "" + "Other": "1. Sign in to the Microsoft Entra admin center and go to Protection > Conditional Access > Policies\n2. Click New policy\n3. Users or workload identities: select All users\n4. Target resources (Cloud apps): select All cloud apps\n5. Conditions > User risk: set Configure to Yes and select High\n6. Access controls > Grant: select Grant access, then check Require multifactor authentication and Require password change; set Require all selected controls\n7. Enable policy: On, then click Create", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\"\n\n conditions {\n client_app_types = [\"all\"]\n users {\n include_users = [\"All\"] # Critical: targets all users\n }\n applications {\n included_applications = [\"All\"] # Critical: applies to all cloud apps\n }\n user_risk_levels = [\"high\"] # Critical: enforces on high user risk\n }\n\n grant_controls {\n operator = \"AND\" # Critical: require all selected controls\n built_in_controls = [\"mfa\", \"passwordChange\"] # Critical: require MFA and password change\n }\n}\n```" }, "Recommendation": { - "Text": "Enable Identity Protection user risk policies to detect and respond to potential account compromises. Configure Conditional Access policies to enforce MFA or password resets when a high user risk level is detected. Regularly review the Risky Users section to assess potential threats before enforcing strict access controls.", - "Url": "https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-configure-risk-policies" + "Text": "Adopt **least privilege** by enabling an active user-risk policy that:\n- covers `All` users and apps (exclude only break-glass)\n- triggers on `High` user risk\n- requires **MFA** and a **secure password change** together\n- reauthenticates risky sessions\n\nPair with sign-in risk policies, ensure MFA registration, and review risky-user reports to validate effectiveness.", + "Url": "https://hub.prowler.com/check/entra_identity_protection_user_risk_enabled" } }, "Categories": [ + "identity-access", "e5" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.metadata.json b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.metadata.json index 300f48a642..936b973c11 100644 --- a/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.metadata.json +++ b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.metadata.json @@ -1,34 +1,38 @@ { "Provider": "m365", "CheckID": "entra_intune_enrollment_sign_in_frequency_every_time", - "CheckTitle": "Ensure sign-in frequency for Intune Enrollment is set to every time", + "CheckTitle": "Conditional Access requires strong authentication and Every Time sign-in frequency for Intune Enrollment", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that Conditional Access policies enforce sign-in frequency to Every time for Microsoft Intune Enrollment Application.", - "Risk": "If not enforced, attackers with compromised credentials may enroll a new device into Intune and gain persistent and elevated access through a bypass of compliance-based Conditional Access rules.", - "RelatedUrl": "https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/deployment-guide-enrollment", + "Description": "Microsoft Entra **Conditional Access** for **Microsoft Intune Enrollment** must require **strong authentication** and set **sign-in frequency** to `Every time` for all users.\n\nThis check evaluates whether an active policy targets the Intune Enrollment app, requires MFA or authentication strength, and forces reauthentication on each enrollment attempt.", + "Risk": "Absent strong authentication and `Every time` reauthentication at enrollment, attackers with stolen or replayed credentials can enroll rogue devices and obtain compliant access.\n\nImpacts:\n- Confidentiality: data exposure from unauthorized devices\n- Integrity: untrusted endpoints modifying resources\n- Availability: persistence via device-based access paths", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/deployment-guide-enrollment", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-session#sign-in-frequency" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "az rest --method POST --url https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies --headers 'Content-Type=application/json' --body '{\"displayName\":\"Intune Enrollment - MFA and Every time\",\"state\":\"enabled\",\"conditions\":{\"users\":{\"includeUsers\":[\"All\"]},\"applications\":{\"includeApplications\":[\"d4ebce55-015a-49b5-a083-c84d1797ae8c\"]}},\"grantControls\":{\"operator\":\"OR\",\"builtInControls\":[\"mfa\"]},\"sessionControls\":{\"signInFrequency\":{\"isEnabled\":true,\"type\":\"everyTime\"}}}'", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. o Under Users include All users. o Under Target resources select Resources (formerly cloud apps), choose Select resources and add Microsoft Intune Enrollment to the list. 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 until the organization is ready to enable it. 5. Click Create", - "Terraform": "" + "Other": "1. Sign in to Microsoft Entra admin center (entra.microsoft.com)\n2. Go to Protection > Conditional Access > Policies > New policy\n3. Users > Include: select All users\n4. Target resources (Resources/Cloud apps) > Select resources: choose Microsoft Intune Enrollment (App ID: `d4ebce55-015a-49b5-a083-c84d1797ae8c`)\n5. Grant > Grant access: select either Require multifactor authentication or Require authentication strength\n6. Session > Sign-in frequency: select Every time\n7. Enable policy: On\n8. Create the policy", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\"\n\n conditions {\n users {\n include_users = [\"All\"] # critical: include all users\n }\n applications {\n include_applications = [\"d4ebce55-015a-49b5-a083-c84d1797ae8c\"] # critical: target Microsoft Intune Enrollment app\n }\n }\n\n session_controls {\n sign_in_frequency {\n is_enabled = true # critical: enable sign-in frequency control\n type = \"everyTime\" # critical: require reauthentication every time\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Configure a Conditional Access policy that targets Microsoft Intune Enrollment and enforces sign-in frequency to 'Every time'. This ensures that users must reauthenticate for each Intune enrollment action, reducing the risk of unauthorized device enrollment using compromised credentials. Note: Microsoft accounts for a five-minute clock skew when 'every time' is selected, ensuring users are not prompted more frequently than once every five minutes.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-session#sign-in-frequency" + "Text": "Implement a **Conditional Access** policy on the **Intune Enrollment** app that requires **MFA** or **authentication strength** and sets sign-in frequency to `Every time`.\n\nMicrosoft Entra requires this grant control when `Every time` is configured for Intune Enrollment, so Prowler validates both conditions together in a single check.", + "Url": "https://hub.prowler.com/check/entra_intune_enrollment_sign_in_frequency_every_time" } }, "Categories": [ - "e3", - "e5" + "identity-access", + "e3" ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "This check intentionally validates both the grant control and session control together. Microsoft Entra requires `Require multifactor authentication` or `Require authentication strength` when `Sign-in frequency = Every time` is configured for Microsoft Intune Enrollment, so these conditions cannot be meaningfully separated into independent policies for this scenario." } diff --git a/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.py b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.py index 8fc1ddbbc3..54e0a26fb4 100644 --- a/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.py +++ b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.py @@ -1,13 +1,15 @@ 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, + GrantControlOperator, SignInFrequencyInterval, ) class entra_intune_enrollment_sign_in_frequency_every_time(Check): - """Ensure sign-in frequency for Intune Enrollment is set to 'Every time'.""" + """Ensure Intune enrollment enforces strong auth and Every Time sign-in.""" def execute(self) -> list[CheckReportM365]: """Execute the check to ensure that sign-in frequency for Intune Enrollment is set to 'Every time'. @@ -24,7 +26,10 @@ class entra_intune_enrollment_sign_in_frequency_every_time(Check): resource_id="conditionalAccessPolicies", ) report.status = "FAIL" - report.status_extended = "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + report.status_extended = ( + "No Conditional Access Policy requires strong authentication and " + "enforces Every Time sign-in frequency for Intune Enrollment." + ) for policy in entra_client.conditional_access_policies.values(): if policy.state == ConditionalAccessPolicyState.DISABLED: @@ -45,6 +50,23 @@ class entra_intune_enrollment_sign_in_frequency_every_time(Check): if "All" not in policy.conditions.user_conditions.included_users: continue + requires_mfa = ( + ConditionalAccessGrantControl.MFA + in policy.grant_controls.built_in_controls + ) + requires_authentication_strength = ( + policy.grant_controls.authentication_strength is not None + ) + + if not (requires_mfa or requires_authentication_strength): + continue + + if ( + policy.grant_controls.operator == GrantControlOperator.OR + and len(policy.grant_controls.built_in_controls) > 1 + ): + continue + if not policy.session_controls.sign_in_frequency.is_enabled: continue @@ -60,10 +82,18 @@ class entra_intune_enrollment_sign_in_frequency_every_time(Check): ) if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: report.status = "FAIL" - report.status_extended = f"Conditional Access Policy {policy.display_name} reports Every Time sign-in frequency for Intune Enrollment but does not enforce it." + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' reports " + "strong authentication and Every Time sign-in frequency for " + "Intune Enrollment but does not enforce them." + ) else: report.status = "PASS" - report.status_extended = f"Conditional Access Policy {policy.display_name} enforces Every Time sign-in frequency for Intune Enrollment." + report.status_extended = ( + f"Conditional Access Policy '{policy.display_name}' requires " + "strong authentication and enforces Every Time sign-in " + "frequency for Intune Enrollment." + ) break findings.append(report) diff --git a/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.metadata.json index aee31bc8cd..7b172d5b5f 100644 --- a/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.metadata.json +++ b/prowler/providers/m365/services/entra/entra_legacy_authentication_blocked/entra_legacy_authentication_blocked.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "entra_legacy_authentication_blocked", - "CheckTitle": "Ensure that Conditional Access policy blocks legacy authentication", + "CheckTitle": "At least one Conditional Access policy blocks legacy authentication", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that Conditional Access policy blocks legacy authentication in Microsoft Entra ID to enforce modern authentication methods and protect against credential-stuffing and brute-force attacks.", - "Risk": "Legacy authentication protocols do not support MFA, making them vulnerable to credential-stuffing and brute-force attacks. Attackers commonly exploit these protocols to bypass security controls and gain unauthorized access.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-legacy-authentication", + "Description": "Microsoft Entra **Conditional Access** has an active policy that blocks **legacy authentication** for `All users` and `All cloud apps` by targeting legacy client app types (e.g., Exchange ActiveSync, other basic-auth clients) and enforcing `Block` access.", + "Risk": "Allowing legacy authentication enables password spray and credential stuffing that bypass **MFA**, leading to account takeover. Compromised sessions threaten **confidentiality** (mail, files), **integrity** (settings, data changes), and **availability**, and enable **lateral movement** across Microsoft 365.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-legacy-authentication" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "az rest --method post --url https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies --body '{\"displayName\":\"\",\"state\":\"enabled\",\"conditions\":{\"users\":{\"includeUsers\":[\"All\"]},\"applications\":{\"includeApplications\":[\"All\"]},\"clientAppTypes\":[\"exchangeActiveSync\",\"other\"]},\"grantControls\":{\"builtInControls\":[\"block\"],\"operator\":\"OR\"}}'", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. Under Users include All users. Under Target resources include All cloud apps and do not create any exclusions. Under Conditions select Client apps and check the boxes for Exchange ActiveSync clients and Other clients. Under Grant select Block Access. Click Select. 4. Set the policy On and click Create.", - "Terraform": "" + "Other": "1. Go to Microsoft Entra admin center > Protection > Conditional Access > Policies\n2. Click New policy\n3. Users: Include > All users\n4. Target resources (cloud apps): Include > All apps\n5. Conditions > Client apps: Configure = Yes; select only Exchange ActiveSync clients and Other clients\n6. Grant > Block access > Select\n7. Enable policy: On, then Create", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\" # critical: enforce the policy\n\n conditions {\n users {\n include_users = [\"All\"] # critical: include all users\n }\n applications {\n include_applications = [\"All\"] # critical: include all cloud apps\n }\n client_app_types = [\"exchangeActiveSync\", \"other\"] # critical: target legacy auth clients\n }\n\n grant_controls {\n built_in_controls = [\"block\"] # critical: block access\n }\n}\n```" }, "Recommendation": { - "Text": "Enforce Conditional Access policies to block legacy authentication across all users in Microsoft Entra ID. Ensure all applications and devices use modern authentication methods such as OAuth 2.0. For necessary exceptions (e.g., multifunction printers), configure secure alternatives following Microsoft's mail flow best practices.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-block-legacy-authentication" + "Text": "Enforce a tenant-wide policy to **block legacy authentication** for `All users` and `All cloud apps`, targeting legacy client app types. Migrate apps and devices to **modern authentication**. Keep minimal, monitored exclusions for break-glass/service accounts, prefer **managed identities**, and apply **zero trust** and **least privilege**.", + "Url": "https://hub.prowler.com/check/entra_legacy_authentication_blocked" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication.metadata.json b/prowler/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication.metadata.json index e2afd026e6..1e695e0d8d 100644 --- a/prowler/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication.metadata.json +++ b/prowler/providers/m365/services/entra/entra_managed_device_required_for_authentication/entra_managed_device_required_for_authentication.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "entra_managed_device_required_for_authentication", - "CheckTitle": "Ensure that only managed devices are required for authentication", + "CheckTitle": "Conditional Access policies require authentication from a managed device for all users and applications", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Conditional Access Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that only managed devices are required for authentication to reduce the risk of unauthorized access from unsecured or unmanaged devices.", - "Risk": "Allowing authentication from unmanaged devices increases the attack surface, as these devices may lack security controls, endpoint detection, and compliance policies. Attackers could leverage compromised credentials from unsecured devices to gain unauthorized access to corporate resources.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "Description": "Microsoft Entra **Conditional Access** evaluates whether an enabled policy targeting `all users` and `all applications` includes grant controls that require a **managed device** (hybrid domain-joined) with **multifactor authentication** during sign-in.", + "Risk": "Sign-ins from **unmanaged devices** erode confidentiality and integrity: compromised hosts can steal tokens, hijack sessions, and exfiltrate data. With leaked credentials, attackers bypass endpoint controls, gain persistent access, and move laterally to alter resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "https://learn.microsoft.com/en-us/mem/intune/protect/create-conditional-access-intune" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "New-MgIdentityConditionalAccessPolicy -DisplayName \"\" -State \"enabled\" -Conditions @{ Users=@{ IncludeUsers=@(\"All\") }; Applications=@{ IncludeApplications=@(\"All\") } } -GrantControls @{ Operator=\"OR\"; BuiltInControls=@(\"mfa\",\"domainJoinedDevice\") }", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. Under Users include All users. Under Target resources include All cloud apps. Under Grant select Grant access. Check Require multifactor authentication and Require Microsoft Entra hybrid joined device. Choose Require one of the selected controls and click Select at the bottom. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create.", - "Terraform": "" + "Other": "1. In Microsoft Entra admin center, go to Entra ID > Security > Conditional Access > Policies\n2. Select New policy\n3. Users: Include > All users\n4. Target resources: Include > All cloud apps\n5. Grant: Select Grant access, check Require multifactor authentication and Require Microsoft Entra hybrid joined device, then choose Require one of the selected controls\n6. Enable policy: On\n7. Create to save", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"example\" {\n display_name = \"\"\n state = \"enabled\" # Critical: must be enabled (not report-only) to enforce\n\n conditions {\n users {\n include_users = [\"All\"] # Critical: target all users\n }\n applications {\n include_applications = [\"All\"] # Critical: target all cloud apps\n }\n }\n\n grant_controls {\n operator = \"OR\" # Critical: require one of the selected controls\n built_in_controls = [\"mfa\", \"domainJoinedDevice\"] # Critical: MFA or Microsoft Entra hybrid joined device\n }\n}\n```" }, "Recommendation": { - "Text": "Enforce Conditional Access policies requiring authentication only from managed devices. Configure policies to allow access only from Entra hybrid joined or Intune-compliant devices. This ensures that only secure, policy-enforced endpoints can access corporate resources, reducing the risk of credential theft and unauthorized access.", - "Url": "https://learn.microsoft.com/en-us/mem/intune/protect/create-conditional-access-intune" + "Text": "Enforce **Conditional Access** to allow only **managed devices** (Entra hybrid joined or Intune-compliant) and require **MFA**, aligning with **Zero Trust** and **least privilege**. Apply to all users and apps, limit exclusions to break-glass accounts, and regularly review device compliance to prevent access from unknown endpoints.", + "Url": "https://hub.prowler.com/check/entra_managed_device_required_for_authentication" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_managed_device_required_for_mfa_registration/entra_managed_device_required_for_mfa_registration.metadata.json b/prowler/providers/m365/services/entra/entra_managed_device_required_for_mfa_registration/entra_managed_device_required_for_mfa_registration.metadata.json index 867cf81eff..ffd60c5166 100644 --- a/prowler/providers/m365/services/entra/entra_managed_device_required_for_mfa_registration/entra_managed_device_required_for_mfa_registration.metadata.json +++ b/prowler/providers/m365/services/entra/entra_managed_device_required_for_mfa_registration/entra_managed_device_required_for_mfa_registration.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "entra_managed_device_required_for_mfa_registration", - "CheckTitle": "Ensure that only managed devices are required for MFA registration", + "CheckTitle": "Tenant has a Conditional Access policy that requires a managed device for MFA registration for all users", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Conditional Access Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that only managed devices are required for MFA registration. This ensures that users enroll MFA using secure, organization-controlled devices.", - "Risk": "If users are allowed to register MFA on unmanaged or potentially compromised devices, attackers with stolen credentials may register their own MFA methods, effectively locking out legitimate users and taking over accounts. This increases the risk of unauthorized access, data breaches, and privilege escalation.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "Description": "Microsoft Entra **Conditional Access** evaluates whether **MFA registration** is restricted to organization-managed devices. It looks for policies that target the security info registration action for all users and require a **managed (compliant or hybrid-joined) device** when registering authentication methods.", + "Risk": "Allowing **MFA enrollment** from unmanaged or compromised devices enables attackers with stolen passwords to add their own factors, causing **account takeover** and potential lockout of the legitimate user.\n\nThis jeopardizes **confidentiality** (data access), **integrity** (unauthorized changes), and **availability** (user access disruption).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/overview", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-device-registration", + "https://entra.microsoft.com." + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "New-MgIdentityConditionalAccessPolicy -BodyParameter @{displayName=\"\";state=\"enabled\";conditions=@{users=@{includeUsers=@(\"All\")};applications=@{includeUserActions=@(\"urn:user:registersecurityinfo\")}};grantControls=@{operator=\"OR\";builtInControls=@(\"mfa\",\"domainJoinedDevice\")}}", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. Under Users include All users. Under Target resources select User actions and check Register security information. Under Grant select Grant access. Check Require multifactor authentication and Require Microsoft Entra hybrid joined device. Choose Require one of the selected controls and click Select at the bottom. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create.", - "Terraform": "" + "Other": "1. Go to Microsoft Entra admin center > Protection > Conditional Access > Policies\n2. Click New policy\n3. Users: Include > All users\n4. Target resources: User actions > check Register security information\n5. Grant: Grant access > check Require multifactor authentication and Require Microsoft Entra hybrid joined device > select Require one of the selected controls\n6. Enable policy: On\n7. Click Create", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\" # Critical: policy must be enforced (not report-only)\n\n conditions {\n users {\n include_users = [\"All\"] # Critical: applies to all users\n }\n applications {\n include_user_actions = [\"urn:user:registersecurityinfo\"] # Critical: targets security info (MFA) registration\n }\n }\n\n grant_controls {\n operator = \"OR\" # Critical: required by the check logic\n built_in_controls = [\"mfa\", \"domainJoinedDevice\"] # Critical: require MFA or hybrid joined device\n }\n}\n```" }, "Recommendation": { - "Text": "Enforce MFA registration only from managed devices by requiring compliance through Intune or Entra hybrid join. This ensures that users enroll MFA using secure, organization-controlled devices.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-device-registration" + "Text": "Enforce **MFA registration** only from **managed devices** using Conditional Access. Apply the policy broadly, with minimal exclusions for break-glass accounts.\n\nAlign with **Zero Trust** and **least privilege** by requiring devices be compliant or hybrid-joined, monitoring enrollment activity, and regularly reviewing policies to prevent bypass and abuse.", + "Url": "https://hub.prowler.com/check/entra_managed_device_required_for_mfa_registration" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_password_hash_sync_enabled/entra_password_hash_sync_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_password_hash_sync_enabled/entra_password_hash_sync_enabled.metadata.json index 668bf253da..e127fb53e5 100644 --- a/prowler/providers/m365/services/entra/entra_password_hash_sync_enabled/entra_password_hash_sync_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_password_hash_sync_enabled/entra_password_hash_sync_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "entra_password_hash_sync_enabled", - "CheckTitle": "Ensure that password hash sync is enabled for hybrid deployments.", + "CheckTitle": "Organization has password hash synchronization enabled for hybrid deployments", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Organization Settings", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure that password hash synchronization is enabled in hybrid Microsoft Entra deployments to facilitate seamless authentication and leaked credential protection.", - "Risk": "If password hash synchronization is not enabled, users may need to maintain multiple passwords, increasing security risks. Additionally, leaked credential detection for hybrid accounts would not be available, reducing the organization's ability to prevent account compromises.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/whatis-phs", + "Description": "Microsoft Entra hybrid tenants use **password hash synchronization** to replicate on-premises Active Directory password hashes to Entra for cloud authentication.\n\n*Applies to hybrid sync scenarios, not fully federated domains.*", + "Risk": "Without **password hash synchronization**, hybrid accounts lose **leaked credential detection** and cloud risk-based protections, weakening confidentiality. Authentication remains tied to on-prem services, reducing availability during outages. Users may reuse passwords across systems, increasing **credential stuffing** exposure.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/whatis-phs", + "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-password-hash-synchronization" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "Set-ADSyncAADCompanyFeature -PasswordHashSync $true", "NativeIaC": "", - "Other": "1. Log in to the on-premises server hosting Microsoft Entra Connect. 2. Open Azure AD Connect and click Configure. 3. Select 'Customize synchronization options' and click Next. 4. Provide admin credentials. 5. On the Optional features screen, check 'Password hash synchronization' and click Next. 6. Click Configure and wait for the process to complete.", + "Other": "1. Sign in to the on-premises server running Microsoft Entra (Azure AD) Connect\n2. Open Azure AD Connect and select Configure\n3. Choose Customize synchronization options and click Next\n4. Sign in with a Global Administrator account\n5. On Optional features, check Password hash synchronization\n6. Click Configure and wait for completion", "Terraform": "" }, "Recommendation": { - "Text": "Enable password hash synchronization in Microsoft Entra Connect to streamline authentication and enhance security monitoring.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/whatis-phs" + "Text": "Enable **password hash synchronization** for hybrid identities and keep it active even alongside federation as a resilient fallback. Combine with **MFA**, **Conditional Access**, and strong password policy enforcement for **defense in depth**. Apply **least privilege** and monitor sign-in risk to prevent account compromise.", + "Url": "https://hub.prowler.com/check/entra_password_hash_sync_enabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants.metadata.json b/prowler/providers/m365/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants.metadata.json index c7b63cb0a2..038c72c510 100644 --- a/prowler/providers/m365/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants.metadata.json +++ b/prowler/providers/m365/services/entra/entra_policy_ensure_default_user_cannot_create_tenants/entra_policy_ensure_default_user_cannot_create_tenants.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "entra_policy_ensure_default_user_cannot_create_tenants", - "CheckTitle": "Ensure that 'Restrict non-admin users from creating tenants' is set to 'Yes'", + "CheckTitle": "Tenant restricts non-admin users from creating tenants", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Authorization Policy", + "Severity": "medium", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Require administrators or appropriately delegated users to create new tenants.", - "Risk": "It is recommended to only allow an administrator to create new tenants. This prevent users from creating new Azure AD or Azure AD B2C tenants and ensures that only authorized users are able to do so.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions", + "Description": "Microsoft Entra authorization policy defines default user permissions, including whether **non-admin users** are `allowed_to_create_tenants`. This evaluates if tenant creation is disabled for default users via `default_user_role_permissions`.", + "Risk": "Allowing default users to create tenants spawns unmanaged shadow tenants. Creators become **Global Administrator**, enabling escalation from compromised accounts and sidestepping governance. This degrades **confidentiality** and **integrity**, widens the **attack surface**, and introduces hidden costs.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions", + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator" + ], "Remediation": { "Code": { - "CLI": "Update-MgPolicyAuthorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateTenants = $false }", + "CLI": "Update-MgPolicyAuthorizationPolicy -AuthorizationPolicyId authorizationPolicy -DefaultUserRolePermissions @{ AllowedToCreateTenants = $false }", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com 2. Click to expand Identity > Users > User settings 3. Set 'Restrict non-admin users from creating tenants' to 'Yes' then 'Save'", + "Other": "1. Go to Microsoft Entra admin center: https://entra.microsoft.com\n2. Navigate to Identity > Users > User settings\n3. Set \"Restrict non-admin users from creating tenants\" to Yes\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enforcing this setting will ensure that only authorized users are able to create new tenants.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator" + "Text": "Enforce **least privilege**: set `allowed_to_create_tenants=false` so only authorized staff-or those with the **Tenant Creator** role-may create tenants. Use **separation of duties** and **PIM** for just-in-time access, and routinely review audit events (e.g., *Create Company*) to deter and detect misuse.", + "Url": "https://hub.prowler.com/check/entra_policy_ensure_default_user_cannot_create_tenants" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles.metadata.json b/prowler/providers/m365/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles.metadata.json index a5c9f3ad59..a2fd6f6ecf 100644 --- a/prowler/providers/m365/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles.metadata.json +++ b/prowler/providers/m365/services/entra/entra_policy_guest_invite_only_for_admin_roles/entra_policy_guest_invite_only_for_admin_roles.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "entra_policy_guest_invite_only_for_admin_roles", - "CheckTitle": "Ensure that 'Guest invite restrictions' is set to 'Only users assigned to specific admin roles can invite guest users'", + "CheckTitle": "Tenant guest invitations are restricted to specific admin roles or disabled", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Authorization Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Restrict invitations to users with specific administrative roles only.", - "Risk": "Restricting invitations to users with specific administrator roles ensures that only authorized accounts have access to cloud resources. This helps to maintain 'Need to Know' permissions and prevents inadvertent access to data. By default the setting Guest invite restrictions is set to Anyone in the organization can invite guest users including guests and non-admins. This would allow anyone within the organization to invite guests and non-admins to the tenant, posing a security risk.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/external-id/external-collaboration-settings-configure", + "Description": "Microsoft Entra authorization policy controls **guest invitations** via `guest_invite_settings`. It should be `adminsAndGuestInviters` or `none`, so only specific **administrative roles** can invite guests-or invitations are disabled.", + "Risk": "Unrestricted invites allow broad creation of external identities. A compromised user can onboard attacker-controlled guests, gaining ongoing access to teams, sites, and apps. This erodes **confidentiality**, enables **privilege abuse**, and complicates revocation and audit.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#guest-inviter", + "https://learn.microsoft.com/en-us/entra/external-id/external-collaboration-settings-configure", + "https://learn.microsoft.com/nb-no/Azure/active-directory/external-identities/external-collaboration-settings-configure", + "https://learn.microsoft.com/en-us/microsoft-365/solutions/limit-who-can-invite-guests?view=o365-worldwide" + ], "Remediation": { "Code": { - "CLI": "Update-MgPolicyAuthorizationPolicy -AllowInvitesFrom 'adminsAndGuestInviters'", + "CLI": "Update-MgPolicyAuthorizationPolicy -AuthorizationPolicyId authorizationPolicy -AllowInvitesFrom adminsAndGuestInviters", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Identity > External Identities and select External collaboration settings. 3. Under Guest invite settings, set 'Guest invite restrictions' to 'Only users assigned to specific admin roles can invite guest users'. 4. Click Save.", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Entra ID > External Identities > External collaboration settings\n3. Under Guest invite settings, select \"Only users assigned to specific admin roles can invite guest users\" (or select \"No one in the organization can invite guest users\" to disable)\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict guest user invitations to only designated administrators or the Guest Inviter role to enhance security.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#guest-inviter" + "Text": "Apply **least privilege**: restrict invites to the **Guest Inviter** or designated admin roles (`adminsAndGuestInviters`), or disable invites (`none`).\n- Require approval and justification\n- Allowlist partner domains and use access reviews\n- Combine with Conditional Access and cross-tenant policies for defense in depth", + "Url": "https://hub.prowler.com/check/entra_policy_guest_invite_only_for_admin_roles" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions.metadata.json b/prowler/providers/m365/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions.metadata.json index 6e4055fadb..536f92ad5d 100644 --- a/prowler/providers/m365/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions.metadata.json +++ b/prowler/providers/m365/services/entra/entra_policy_guest_users_access_restrictions/entra_policy_guest_users_access_restrictions.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "entra_policy_guest_users_access_restrictions", - "CheckTitle": "Ensure That 'Guest users access restrictions' is set to 'Guest user access is restricted to properties and memberships of their own directory objects'", + "CheckTitle": "Authorization policy restricts guest user access to properties and memberships of their own directory objects", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Authorization Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Limit guest user permissions.", - "Risk": "Limiting guest access ensures that guest accounts do not have permission for certain directory tasks, such as enumerating users, groups or other directory resources, and cannot be assigned to administrative roles in your directory. Guest access has three levels of restriction. 1. Guest users have the same access as members (most inclusive), 2. Guest users have limited access to properties and memberships of directory objects (default value), 3. Guest user access is restricted to properties and memberships of their own directory objects (most restrictive). The recommended option is the 3rd, most restrictive: 'Guest user access is restricted to their own directory object'.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions", + "Description": "Microsoft Entra **authorization policy** evaluates **guest user access restrictions** being set to the most restrictive level, where guests can view only their own directory object and related memberships (`Guest user access is restricted to properties and memberships of their own directory objects`).", + "Risk": "Without this restriction, guests can read broader directory metadata and group memberships, enabling reconnaissance that harms **confidentiality**. A compromised guest gains context for phishing and privilege escalation, risking unauthorized changes (**integrity**) and disruption of collaboration spaces (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#member-and-guest-users", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/ActiveDirectory/restrict-guest-user-access.html", + "https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions" + ], "Remediation": { "Code": { - "CLI": "Update-MgPolicyAuthorizationPolicy -GuestUserRoleId ", + "CLI": "Update-MgPolicyAuthorizationPolicy -GuestUserRoleId '2af84b1e-32c8-42b7-82bc-daa82404023b'", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Entra admin center https://entra.microsoft.com/. 2. Expand Identity > External Identities and select External collaboration settings. 3. Under Guest user access, set 'Guest user access restrictions' to either 'Guest users have limited access to properties and memberships of directory objects' or 'Guest user access is restricted to properties and memberships of their own directory objects (most restrictive)'.", - "Terraform": "" + "Other": "1. Sign in to Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > External Identities > External collaboration settings\n3. Under Guest user access, select: \"Guest user access is restricted to properties and memberships of their own directory objects\"\n4. Click Save", + "Terraform": "```hcl\nresource \"azuread_authorization_policy\" \"\" {\n guest_user_role_id = \"2af84b1e-32c8-42b7-82bc-daa82404023b\" # Critical: sets guests to the most restrictive role (own objects only)\n}\n```" }, "Recommendation": { - "Text": "Restrict guest user access in Microsoft Entra to limit the exposure of directory objects and reduce security risks.", - "Url": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#member-and-guest-users" + "Text": "Set guest access to the most restrictive level (`Guest user access is restricted...`) to enforce **least privilege**.\n- Avoid assigning admin roles to guests\n- Use time-bound access with approvals\n- Apply **Conditional Access** and limit group visibility\n- Run periodic **access reviews** for **defense in depth**", + "Url": "https://hub.prowler.com/check/entra_policy_guest_users_access_restrictions" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps.metadata.json b/prowler/providers/m365/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps.metadata.json index c471a42cad..a0a61ed69c 100644 --- a/prowler/providers/m365/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps.metadata.json +++ b/prowler/providers/m365/services/entra/entra_policy_restricts_user_consent_for_apps/entra_policy_restricts_user_consent_for_apps.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "entra_policy_restricts_user_consent_for_apps", - "CheckTitle": "Ensure 'User consent for applications' is set to 'Do not allow user consent'", + "CheckTitle": "User consent for applications is set to 'Do not allow user consent'", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Authorization Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Require administrators to provide consent for applications before use.", - "Risk": "If Microsoft Entra ID is running as an identity provider for third-party applications, permissions and consent should be limited to administrators or pre-approved. Malicious applications may attempt to exfiltrate data or abuse privileged user accounts.", - "RelatedUrl": "https://learn.microsoft.com/en-gb/entra/identity/enterprise-apps/configure-user-consent?pivots=portal", + "Description": "Microsoft Entra **tenant settings** restrict **user consent to applications**, preventing end users from granting delegated permissions to apps on their behalf. Only **administrator-approved** or policy-allowed consents are permitted.", + "Risk": "Allowing end users to grant consent enables **consent phishing** and stealth access to mail, files, and directory data, impacting **confidentiality** and **integrity**. Attackers can obtain long-lived refresh tokens via `offline_access`, persist, and act as the user, evading detection.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-gb/entra/identity/enterprise-apps/configure-user-consent?pivots=portal", + "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "az rest --method patch --url https://graph.microsoft.com/v1.0/policies/authorizationPolicy/authorizationPolicy --body \"{\\\"defaultUserRolePermissions\\\":{\\\"permissionGrantPoliciesAssigned\\\":[\\\"ManagePermissionGrantsForOwnedResource.DeveloperConsent\\\"]}}\"", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Entra admin center (https://entra.microsoft.com/); 2. Click to expand Identity > Applications and select Enterprise applications; 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.", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > Enterprise applications\n3. Select Consent and permissions > User consent settings\n4. Under User consent for applications, select Do not allow user consent\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable user consent for applications in the Microsoft Entra admin center. This ensures that end users and group owners cannot grant consent to applications, requiring administrator approval for all future consent operations, thereby reducing the risk of unauthorized access to company data.", - "Url": "https://learn.microsoft.com/en-gb/entra/identity/enterprise-apps/configure-user-consent?pivots=portal" + "Text": "Disable broad user consent and require **admin approval** for app permissions. If consent is needed, allow only **verified publishers** and low-impact scopes via app consent policies, and enable the **admin consent workflow**. Apply **least privilege**, review grants, and revoke unused consents regularly.", + "Url": "https://hub.prowler.com/check/entra_policy_restricts_user_consent_for_apps" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled.metadata.json b/prowler/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled.metadata.json index 5393a6f810..17c92a4889 100644 --- a/prowler/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_seamless_sso_disabled/entra_seamless_sso_disabled.metadata.json @@ -1,15 +1,15 @@ { "Provider": "m365", "CheckID": "entra_seamless_sso_disabled", - "CheckTitle": "Entra hybrid deployment does not have Seamless SSO enabled", + "CheckTitle": "Hybrid deployment does not have Seamless SSO enabled", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Directory Sync Settings", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "**Seamless Single Sign-On (SSO)** in hybrid Microsoft Entra deployments allows automatic authentication for domain-joined devices on the corporate network.\n\nThis check verifies the actual Seamless SSO configuration in directory synchronization settings. Modern devices with **Primary Refresh Token** (PRT) support no longer require Seamless SSO.", + "Description": "Microsoft Entra hybrid deployments use **Seamless Single Sign-On (SSO)** to allow automatic authentication for domain-joined devices on the corporate network.\n\nThis check verifies the actual Seamless SSO configuration in directory synchronization settings. Modern devices with **Primary Refresh Token** (PRT) support no longer require Seamless SSO.", "Risk": "Seamless SSO can be exploited for **lateral movement** between on-premises domains and Entra ID when an Entra Connect server is compromised. It can also be used to perform **brute force attacks** against Entra ID, as authentication through the AZUREADSSOACC account bypasses standard protections.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 3c9b64b775..7ea9c75356 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -35,6 +35,7 @@ class Entra(M365Service): users (dict): Dictionary of users. user_accounts_status (dict): Dictionary of user account statuses. oauth_apps (dict): Dictionary of OAuth applications from Defender XDR. + authentication_method_configurations (dict): Dictionary of authentication method configurations. """ def __init__(self, provider: M365Provider): @@ -81,6 +82,7 @@ class Entra(M365Service): self._get_default_app_management_policy(), self._get_oauth_apps(), self._get_directory_sync_settings(), + self._get_authentication_method_configurations(), ) ) @@ -93,6 +95,9 @@ class Entra(M365Service): self.default_app_management_policy = attributes[6] self.oauth_apps: Optional[Dict[str, OAuthApp]] = attributes[7] self.directory_sync_settings, self.directory_sync_error = attributes[8] + self.authentication_method_configurations: Dict[ + str, AuthenticationMethodConfiguration + ] = attributes[9] self.user_accounts_status = {} if created_loop: @@ -167,6 +172,18 @@ class Entra(M365Service): conditional_access_policies_list = ( await self.client.identity.conditional_access.policies.get() ) + + # TODO: Remove this workaround once microsoft/kiota-python#515 is + # fixed and a new version of microsoft-kiota-serialization-json is + # released (see PR microsoft/kiota-python#516). At that point, use + # the SDK's native deserialization for authentication_flows instead. + # + # The SDK deserializer uses get_collection_of_enum_values for + # transferMethods, but the Graph API returns it as a single string + # (e.g., "deviceCodeFlow"), causing the SDK to return an empty list. + # We fetch the raw JSON to correctly parse transferMethods. + raw_auth_flows_map = await self._get_raw_authentication_flows() + for policy in conditional_access_policies_list.value: conditional_access_policies[policy.id] = ConditionalAccessPolicy( id=policy.id, @@ -294,6 +311,24 @@ class Entra(M365Service): [], ) ], + # The MS Graph SDK deserializes insiderRiskLevels + # as a list via get_collection_of_enum_values, so + # we take the first element when present. + insider_risk_levels=( + InsiderRiskLevel(raw_insider_risk[0].value) + if ( + raw_insider_risk := getattr( + policy.conditions, + "insider_risk_levels", + None, + ) + ) + and raw_insider_risk + else None + ), + authentication_flows=self._parse_authentication_flows( + raw_auth_flows_map.get(policy.id) + ), ), grant_controls=GrantControls( built_in_controls=( @@ -439,6 +474,76 @@ class Entra(M365Service): ) return default_app_management_policy + async def _get_raw_authentication_flows(self) -> dict: + """Fetch authentication flows from the Graph API using a raw JSON request. + + TODO: Remove this method once microsoft/kiota-python#515 is fixed and + a new version of microsoft-kiota-serialization-json is released + (see PR microsoft/kiota-python#516). At that point, revert to using + the SDK's native deserialization via policy.conditions.authentication_flows. + + The SDK deserializer incorrectly handles the transferMethods field + (uses get_collection_of_enum_values for a single string value), + so we fetch the raw JSON to correctly parse it. + + Returns: + A dict mapping policy ID to the raw authenticationFlows dict. + """ + auth_flows_map = {} + try: + request_info = ( + self.client.identity.conditional_access.policies.to_get_request_information() + ) + request_info.headers.try_add("Prefer", "include-unknown-enum-members") + response = await self.client.request_adapter.send_primitive_async( + request_info, "bytes", {} + ) + if response: + data = json.loads(response) + for policy in data.get("value", []): + policy_id = policy.get("id") + auth_flows = policy.get("conditions", {}).get("authenticationFlows") + if policy_id and auth_flows: + auth_flows_map[policy_id] = auth_flows + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return auth_flows_map + + @staticmethod + def _parse_authentication_flows(auth_flows) -> "AuthenticationFlows | None": + """Parse authentication flows from a raw JSON dict. + + TODO: Remove this method once microsoft/kiota-python#515 is fixed and + revert to parsing the SDK's ConditionalAccessAuthenticationFlows object + directly (see PR microsoft/kiota-python#516). + + Args: + auth_flows: A dict from the raw JSON response (e.g., {"transferMethods": "deviceCodeFlow"}). + + Returns: + AuthenticationFlows object or None if not present. + """ + if not auth_flows: + return None + + transfer_methods = [] + raw_value = auth_flows.get("transferMethods") + if raw_value: + # The API may return a single string or a comma-separated value + methods = raw_value.split(",") if isinstance(raw_value, str) else raw_value + for method_str in methods: + method_str = method_str.strip() + try: + transfer_methods.append(TransferMethod(method_str)) + except ValueError: + logger.warning( + f"Unknown authentication flow transfer method: {method_str}" + ) + + return AuthenticationFlows(transfer_methods=transfer_methods) + @staticmethod def _parse_app_management_restrictions(restrictions): """Parse credential restrictions from the Graph API response into AppManagementRestrictions.""" @@ -607,6 +712,7 @@ class Entra(M365Service): while users_response: for user in getattr(users_response, "value", []) or []: + reg_info = registration_details.get(user.id, {}) users[user.id] = User( id=user.id, name=user.display_name, @@ -614,10 +720,13 @@ class Entra(M365Service): True if (user.on_premises_sync_enabled) else False ), directory_roles_ids=user_roles_map.get(user.id, []), - is_mfa_capable=(registration_details.get(user.id, False)), + is_mfa_capable=reg_info.get("is_mfa_capable", False), account_enabled=not self.user_accounts_status.get( user.id, {} ).get("AccountDisabled", False), + authentication_methods=reg_info.get( + "authentication_methods", [] + ), ) next_link = getattr(users_response, "odata_next_link", None) @@ -631,6 +740,16 @@ class Entra(M365Service): return users async def _get_user_registration_details(self): + """Retrieve user authentication method registration details. + + Fetches registration details from the Microsoft Graph API, including + MFA capability and the specific authentication methods each user has registered. + + Returns: + dict: A dictionary mapping user IDs to their registration details, + where each value is a dict with 'is_mfa_capable' (bool) and + 'authentication_methods' (list of str). + """ registration_details = {} try: registration_builder = ( @@ -640,9 +759,14 @@ class Entra(M365Service): while registration_response: for detail in getattr(registration_response, "value", []) or []: - registration_details.update( - {detail.id: getattr(detail, "is_mfa_capable", False)} - ) + registration_details[detail.id] = { + "is_mfa_capable": getattr(detail, "is_mfa_capable", False), + "authentication_methods": [ + str(method) + for method in getattr(detail, "methods_registered", []) + or [] + ], + } next_link = getattr(registration_response, "odata_next_link", None) if not next_link: @@ -778,6 +902,41 @@ OAuthAppInfo return oauth_apps + async def _get_authentication_method_configurations(self): + """Retrieve authentication method configurations from Microsoft Entra. + + Fetches the authentication methods policy and extracts the configuration + state for each authentication method (e.g., SMS, Voice, FIDO2, etc.). + + Returns: + Dict[str, AuthenticationMethodConfiguration]: Dictionary of authentication + method configurations keyed by method ID (e.g., 'sms', 'voice'). + """ + logger.info("Entra - Getting authentication method configurations...") + authentication_method_configurations = {} + try: + policy = await self.client.policies.authentication_methods_policy.get() + for config in ( + getattr(policy, "authentication_method_configurations", []) or [] + ): + method_id = getattr(config, "id", "") + if method_id: + authentication_method_configurations[method_id] = ( + AuthenticationMethodConfiguration( + id=method_id, + state=( + getattr(config, "state", None).value + if getattr(config, "state", None) + else "disabled" + ), + ) + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return authentication_method_configurations + class ConditionalAccessPolicyState(Enum): ENABLED = "enabled" @@ -820,8 +979,19 @@ class ClientAppType(Enum): OTHER_CLIENTS = "other" +class InsiderRiskLevel(Enum): + """Insider risk levels for Conditional Access policies. + + Reference: https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessconditionset#conditionalaccessinsiderrisklevels-values + """ + + MINOR = "minor" + MODERATE = "moderate" + ELEVATED = "elevated" + + class PlatformConditions(BaseModel): - """Model representing device platform conditions for Conditional Access policies.""" + """Model representing platform conditions for Conditional Access policies.""" include_platforms: List[str] = Field( default_factory=list, alias="included_platforms" @@ -834,6 +1004,19 @@ class PlatformConditions(BaseModel): allow_population_by_field_name = True +class TransferMethod(Enum): + """Transfer methods for authentication flows in Conditional Access policies.""" + + DEVICE_CODE_FLOW = "deviceCodeFlow" + AUTHENTICATION_TRANSFER = "authenticationTransfer" + + +class AuthenticationFlows(BaseModel): + """Model representing authentication flows conditions in Conditional Access policies.""" + + transfer_methods: List[TransferMethod] = [] + + class Conditions(BaseModel): application_conditions: Optional[ApplicationsConditions] user_conditions: Optional[UsersConditions] @@ -841,6 +1024,8 @@ class Conditions(BaseModel): platform_conditions: Optional[PlatformConditions] = None user_risk_levels: List[RiskLevel] = [] sign_in_risk_levels: List[RiskLevel] = [] + insider_risk_levels: Optional[InsiderRiskLevel] = None + authentication_flows: Optional[AuthenticationFlows] = None class PersistentBrowser(BaseModel): @@ -953,6 +1138,21 @@ class DirectorySyncSettings(BaseModel): seamless_sso_enabled: bool = False +class AuthenticationMethodConfiguration(BaseModel): + """Authentication method configuration from the authentication methods policy. + + Represents the state of a specific authentication method (e.g., SMS, Voice, + FIDO2) within the tenant's authentication methods policy. + + Attributes: + id: The authentication method identifier (e.g., 'sms', 'voice'). + state: The state of the authentication method ('enabled' or 'disabled'). + """ + + id: str + state: str = "disabled" + + class Group(BaseModel): id: str name: str @@ -1012,12 +1212,26 @@ class AdminRoles(Enum): class User(BaseModel): + """Model representing a Microsoft Entra ID user. + + Attributes: + id: The user's unique identifier. + name: The user's display name. + on_premises_sync_enabled: Whether the user is synced from on-premises directory. + directory_roles_ids: List of directory role template IDs assigned to the user. + is_mfa_capable: Whether the user has registered a strong authentication method for MFA. + account_enabled: Whether the user account is enabled. + authentication_methods: List of authentication method types registered by the user + (e.g., 'fido2SecurityKey', 'microsoftAuthenticatorPush', 'mobilePhone'). + """ + id: str name: str on_premises_sync_enabled: bool directory_roles_ids: List[str] = [] is_mfa_capable: bool = False account_enabled: bool = True + authentication_methods: List[str] = [] class InvitationsFrom(Enum): diff --git a/prowler/providers/m365/services/entra/entra_thirdparty_integrated_apps_not_allowed/entra_thirdparty_integrated_apps_not_allowed.metadata.json b/prowler/providers/m365/services/entra/entra_thirdparty_integrated_apps_not_allowed/entra_thirdparty_integrated_apps_not_allowed.metadata.json index 7d3f9a4ca8..bef11d3a45 100644 --- a/prowler/providers/m365/services/entra/entra_thirdparty_integrated_apps_not_allowed/entra_thirdparty_integrated_apps_not_allowed.metadata.json +++ b/prowler/providers/m365/services/entra/entra_thirdparty_integrated_apps_not_allowed/entra_thirdparty_integrated_apps_not_allowed.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "entra_thirdparty_integrated_apps_not_allowed", - "CheckTitle": "Ensure third party integrated applications are not allowed", + "CheckTitle": "Authorization policy disallows app creation by non-admin users", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "User settings", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Require administrators or appropriately delegated users to register third-party applications.", - "Risk": "It is recommended to only allow an administrator to register custom-developed applications. This ensures that the application undergoes a formal security review and approval process prior to exposing Azure Active Directory data. Certain users like developers or other high-request users may also be delegated permissions to prevent them from waiting on an administrative user. Your organization should review your policies and decide your needs.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity-platform/how-applications-are-added#who-has-permission-to-add-applications-to-my-microsoft-entra-instance", + "Description": "Microsoft Entra **authorization policy** restricts registration of **third-party applications**, verifying that **non-admin users** cannot create app registrations and that only administrators or explicitly delegated roles can add integrated apps.", + "Risk": "Allowing users to create apps enables **consent phishing** and uncontrolled **service principals** with long-lived secrets, risking **data exfiltration** via over-privileged API access, **privilege escalation** through abused app permissions, and tenant **persistence**. This degrades confidentiality, integrity, and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/delegate-app-roles#restrict-who-can-create-applications", + "https://learn.microsoft.com/en-us/entra/identity-platform/how-applications-are-added#who-has-permission-to-add-applications-to-my-microsoft-entra-instance" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy/authorizationPolicy' -Body '{\"defaultUserRolePermissions\":{\"allowedToCreateApps\":false}}' -ContentType 'application/json'", "NativeIaC": "", - "Other": "1. From Entra select the Portal Menu 2. Select Azure Active Directory 3. Select Users 4. Select User settings 5. Ensure that Users can register applications is set to No", + "Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Identity > Users > User settings\n3. Set \"Users can register applications\" to \"No\"\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable third-party integrated application permissions unless explicitly required. If third-party applications are necessary, implement strict approval processes and security controls to mitigate risks associated with external integrations.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/delegate-app-roles#restrict-who-can-create-applications" + "Text": "Restrict app registration to administrators or narrowly scoped delegated roles, following **least privilege** and **separation of duties**. Require **admin consent** and formal review for external integrations, disable broad user consent, and audit app creations and permissions to enforce **defense in depth**.", + "Url": "https://hub.prowler.com/check/entra_thirdparty_integrated_apps_not_allowed" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.metadata.json b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.metadata.json index e7a8ecce89..e2bf85355b 100644 --- a/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.metadata.json +++ b/prowler/providers/m365/services/entra/entra_users_mfa_capable/entra_users_mfa_capable.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "entra_users_mfa_capable", - "CheckTitle": "Ensure all users are MFA capable", + "CheckTitle": "User is MFA capable", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Conditional Access Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure all users are being registered and enabled for multifactor authentication.", - "Risk": "Users who are not MFA capable are more vulnerable to account compromise, as they may rely solely on single-factor authentication (typically a password), which can be easily phished or cracked.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks", + "Description": "Microsoft Entra users have a registered and enabled **multifactor authentication** method (`MFA capable`). The evaluation targets enabled accounts and identifies those lacking any usable second factor.", + "Risk": "Without **MFA**, accounts are vulnerable to **phishing**, **password spraying**, and credential reuse, enabling takeover. Attackers can access mail and files, change settings, and move laterally, harming **confidentiality**, **integrity**, and **availability** of M365 resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-userdevicesettings", + "https://www.cisa.gov/resources-tools/services/m365-entra-id", + "https://azure.microsofts.workers.dev/en-us/entra/identity/authentication/howto-mfa-userstates", + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "New-MgUserAuthenticationPhoneMethod -UserId -PhoneType mobile -PhoneNumber \"+15555550100\"", "NativeIaC": "", - "Other": "Remediation steps will depend on the status of the personnel in question or configuration of Conditional Access policies. Administrators should review each user identified on a case-by-case basis.", + "Other": "1. In the Microsoft Entra admin center, go to Entra ID > Users\n2. Select the user marked as not MFA capable\n3. Select Authentication methods > + Add authentication method\n4. Choose Phone number, enter the number in E.164 format (e.g., +15555550100), and select Add\n5. Repeat for each failing user", "Terraform": "" }, "Recommendation": { - "Text": "Ensure all member users are MFA capable by registering and enabling a strong authentication method that complies with the organization's authentication policy. Regularly review user status to detect gaps in MFA deployment and correct misconfigurations.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks" + "Text": "Enforce **MFA** for all enabled users, prioritizing **phishing-resistant** methods (`FIDO2`/`passkeys`/`CBA`) and limiting `SMS`/`voice`. Apply least privilege and require MFA for privileged roles. Require registration during onboarding and routinely review coverage to sustain defense-in-depth.", + "Url": "https://hub.prowler.com/check/entra_users_mfa_capable" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/entra/entra_users_mfa_enabled/entra_users_mfa_enabled.metadata.json b/prowler/providers/m365/services/entra/entra_users_mfa_enabled/entra_users_mfa_enabled.metadata.json index 805372837d..016caac091 100644 --- a/prowler/providers/m365/services/entra/entra_users_mfa_enabled/entra_users_mfa_enabled.metadata.json +++ b/prowler/providers/m365/services/entra/entra_users_mfa_enabled/entra_users_mfa_enabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "entra_users_mfa_enabled", - "CheckTitle": "Ensure multifactor authentication is enabled for all users.", + "CheckTitle": "Multifactor authentication is enforced for all users", "CheckType": [], "ServiceName": "entra", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Conditional Access Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Ensure that multifactor authentication (MFA) is enabled for all users to enhance security and reduce the risk of unauthorized access.", - "Risk": "Without multifactor authentication (MFA), users are at a higher risk of account compromise due to credential theft, phishing, or brute-force attacks. A single-factor authentication method, such as passwords, is often insufficient to protect against modern cyber threats.", - "RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa", + "Description": "Microsoft Entra **Conditional Access** has an enforced policy requiring **multifactor authentication** for `All users` across `All cloud apps` *(not just report-only)*.", + "Risk": "Lacking an enforced, tenant-wide **MFA** mandate enables single-factor sign-ins to M365 apps. Stolen or sprayed passwords can yield access, leading to data exfiltration, unauthorized changes, and outages. Report-only or scoped policies leave gaps that undermine confidentiality, integrity, and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-mfa-strength", + "https://docs.azure.cn/en-us/entra/identity/conditional-access/policy-guests-mfa-strength" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Click New policy. Under Users include All users (and do not exclude any user). Under Target resources include All cloud apps and do not create any exclusions. Under Grant select Grant Access and check Require multifactor authentication. Click Select at the bottom of the pane. 4. Under Enable policy set it to Report Only until the organization is ready to enable it. 5. Click Create.", - "Terraform": "" + "Other": "1. Sign in to Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Protection > Conditional Access > Policies > Create new policy\n3. Users: Include > All users (do not add exclusions)\n4. Target resources: Resources (cloud apps) > Include > All resources (no exclusions)\n5. Access controls: Grant > Grant access > check Require multifactor authentication > Select\n6. Enable policy: On\n7. Create", + "Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"\" {\n display_name = \"\"\n state = \"enabled\" # Critical: enforce policy (not report-only)\n\n conditions {\n users {\n included_users = [\"All\"] # Critical: target all users\n }\n applications {\n included_applications = [\"All\"] # Critical: target all cloud apps/resources\n }\n }\n\n grant_controls {\n built_in_controls = [\"mfa\"] # Critical: require multifactor authentication\n }\n}\n```" }, "Recommendation": { - "Text": "Enable multifactor authentication for all users in the Microsoft 365 tenant. Ensure users register at least one strong second-factor authentication method, such as Microsoft Authenticator, SMS codes, or phone calls. Educate users on the importance of MFA and provide clear instructions for enrollment to minimize disruptions.", - "Url": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa" + "Text": "Enforce a **Conditional Access** policy requiring **MFA** for `All users` and `All cloud apps`. Exclude only break-glass accounts, favor **phishing-resistant** or authenticator methods, and avoid long-term report-only. Monitor sign-ins, review coverage regularly, and apply **least privilege** and **zero trust** to minimize exceptions.", + "Url": "https://hub.prowler.com/check/entra_users_mfa_enabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_external_email_tagging_enabled/exchange_external_email_tagging_enabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_external_email_tagging_enabled/exchange_external_email_tagging_enabled.metadata.json index a33543d8b3..97fa663760 100644 --- a/prowler/providers/m365/services/exchange/exchange_external_email_tagging_enabled/exchange_external_email_tagging_enabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_external_email_tagging_enabled/exchange_external_email_tagging_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "exchange_external_email_tagging_enabled", - "CheckTitle": "Ensure email from external senders is identified.", + "CheckTitle": "Identity has external sender tagging enabled", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Exchange External Mail Tagging", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that emails from external senders are identified using the native External tag experience in Outlook clients, which helps users recognize messages originating outside the organization.", - "Risk": "If external email tagging is not enabled, users may be unable to quickly identify emails coming from outside the organization, increasing the risk of phishing or social engineering attacks.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-externalinoutlook?view=exchange-ps", + "Description": "**Microsoft 365 Exchange Online** uses native external sender identification so supported Outlook clients display an `External` tag on messages originating outside the organization.", + "Risk": "Without the native tag, users lose a clear signal that a message is from outside the tenant, increasing susceptibility to **phishing**, **BEC**, and credential theft. This raises risks to **confidentiality** (exfiltration) and **integrity** (fraudulent approvals) via social engineering and reply-chain attacks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "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/exchangepowershell/set-externalinoutlook?view=exchange-ps" + ], "Remediation": { "Code": { "CLI": "Set-ExternalInOutlook -Enabled $true", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to the Exchange admin center: https://admin.exchange.microsoft.com\n2. Navigate to Mail flow > External tagging\n3. Turn on Enable external tagging in Outlook\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable the External tag for Outlook to help users visually identify emails from outside the organization.", - "Url": "https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098" + "Text": "Enable native external sender identification and prefer it over subject-line modifications. Apply **defense in depth**: enforce **anti-phishing** protections, validate senders with SPF/DKIM/DMARC, and deliver user training. *Use exceptions sparingly* for trusted domains to reduce noise while preserving **least privilege** in communication paths.", + "Url": "https://hub.prowler.com/check/exchange_external_email_tagging_enabled" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_audit_bypass_disabled/exchange_mailbox_audit_bypass_disabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_mailbox_audit_bypass_disabled/exchange_mailbox_audit_bypass_disabled.metadata.json index d454bbec4c..21d1893982 100644 --- a/prowler/providers/m365/services/exchange/exchange_mailbox_audit_bypass_disabled/exchange_mailbox_audit_bypass_disabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_mailbox_audit_bypass_disabled/exchange_mailbox_audit_bypass_disabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "exchange_mailbox_audit_bypass_disabled", - "CheckTitle": "Ensure 'AuditBypassEnabled' is not enabled on any mailbox in the organization.", + "CheckTitle": "Mailbox has AuditBypassEnabled disabled", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Mailboxes", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that no mailboxes in the organization have 'AuditBypassEnabled' set to true. This setting prevents mailbox audit logging and can allow unauthorized access without traceability.", - "Risk": "If 'AuditBypassEnabled' is set to true for any mailbox, access to those mailboxes won't be logged, creating a blind spot in forensic analysis and increasing the risk of undetected malicious activity.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/get-mailboxauditbypassassociation?view=exchange-ps", + "Description": "**Microsoft 365 Exchange Online mailboxes** are evaluated for **audit logging bypass** by reviewing the `AuditBypassEnabled` setting and identifying mailboxes where auditing can be circumvented.", + "Risk": "**Bypassed mailbox auditing** removes visibility into access and actions, weakening detective controls. Covert data exfiltration, inbox-rule abuse, and persistence become harder to spot, harming **confidentiality** and **integrity** and impeding **forensics**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/exchange/set-mailboxauditbypassassociation?view=exchange-ps" + ], "Remediation": { "Code": { - "CLI": "$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 }", + "CLI": "Get-MailboxAuditBypassAssociation -ResultSize unlimited | Where-Object {$_.AuditBypassEnabled} | ForEach-Object { Set-MailboxAuditBypassAssociation -Identity $_.Identity -AuditBypassEnabled $false }", "NativeIaC": "", - "Other": "", + "Other": "1. Open PowerShell and connect to Exchange Online: Connect-ExchangeOnline\n2. Run:\n```\nGet-MailboxAuditBypassAssociation -ResultSize unlimited | Where-Object {$_.AuditBypassEnabled} | ForEach-Object { Set-MailboxAuditBypassAssociation -Identity $_.Identity -AuditBypassEnabled $false }\n```", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that no mailboxes have 'AuditBypassEnabled' enabled to guarantee full audit logging for all mailbox activities.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-mailboxauditbypassassociation?view=exchange-ps" + "Text": "Disable audit bypass by keeping `AuditBypassEnabled` set to `false` for all accounts. Apply **least privilege** to service identities, use dedicated accounts for automation, and monitor for bypass associations with alerts. Enforce **separation of duties** and preserve tamper-resistant audit logs.", + "Url": "https://hub.prowler.com/check/exchange_mailbox_audit_bypass_disabled" } }, "Categories": [ + "logging", + "forensics-ready", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_mailbox_policy_additional_storage_restricted/exchange_mailbox_policy_additional_storage_restricted.metadata.json b/prowler/providers/m365/services/exchange/exchange_mailbox_policy_additional_storage_restricted/exchange_mailbox_policy_additional_storage_restricted.metadata.json index 56f0d8d305..f9dc5c520b 100644 --- a/prowler/providers/m365/services/exchange/exchange_mailbox_policy_additional_storage_restricted/exchange_mailbox_policy_additional_storage_restricted.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_mailbox_policy_additional_storage_restricted/exchange_mailbox_policy_additional_storage_restricted.metadata.json @@ -1,31 +1,35 @@ { "Provider": "m365", "CheckID": "exchange_mailbox_policy_additional_storage_restricted", - "CheckTitle": "Ensure additional storage providers are restricted in Outlook on the web.", + "CheckTitle": "Mailbox policy has additional storage providers disabled", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Mailboxes Policy", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Restrict the availability of additional storage providers (e.g., Box, Dropbox, Google Drive) in Outlook on the web to prevent users from accessing external storage services through the OWA interface.", - "Risk": "Allowing users to access third-party storage providers from Outlook on the web increases the risk of data exfiltration and exposure to untrusted content or malware.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-owamailboxpolicy?view=exchange-ps", + "Description": "**Microsoft 365 Outlook on the web mailbox policy** governs access to **additional storage providers** (e.g., Box, Dropbox, Google Drive, personal OneDrive). The finding evaluates whether these third-party file integrations are disabled via `AdditionalStorageProvidersAvailable=false`.", + "Risk": "Enabling third-party storage in OWA weakens:\n- **Confidentiality**: data can leave the tenant to unmanaged clouds\n- **Integrity**: external links can deliver or reference malicious/tampered files\n- **Visibility/Compliance**: M365 DLP and audit may not fully apply, enabling undetected exfiltration", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/exchange/set-owamailboxpolicy?view=exchange-ps" + ], "Remediation": { "Code": { "CLI": "Set-OwaMailboxPolicy -Identity OwaMailboxPolicy-Default -AdditionalStorageProvidersAvailable $false", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to the Exchange admin center (https://admin.exchange.microsoft.com)\n2. Open Classic Exchange admin center (left pane)\n3. Go to Permissions > Outlook Web App policies\n4. Edit OwaMailboxPolicy-Default\n5. In Features, set \"Additional storage providers\" to Off\n6. Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable access to additional storage providers in Outlook on the web to reduce the risk of data leakage.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-owamailboxpolicy?view=exchange-ps" + "Text": "Block third-party storage integrations in the OWA mailbox policy (`AdditionalStorageProvidersAvailable=false`). Prefer **enterprise-managed repositories**, enforce **least privilege**, and apply **DLP** and **Conditional Access** to control egress. *If required*, permit only vetted providers under **governed exceptions** with monitoring.", + "Url": "https://hub.prowler.com/check/exchange_mailbox_policy_additional_storage_restricted" } }, "Categories": [ - "e3" + "e3", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/m365/services/exchange/exchange_organization_mailbox_auditing_enabled/exchange_organization_mailbox_auditing_enabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_organization_mailbox_auditing_enabled/exchange_organization_mailbox_auditing_enabled.metadata.json index e06fee6dfa..b690d94700 100644 --- a/prowler/providers/m365/services/exchange/exchange_organization_mailbox_auditing_enabled/exchange_organization_mailbox_auditing_enabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_organization_mailbox_auditing_enabled/exchange_organization_mailbox_auditing_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "exchange_organization_mailbox_auditing_enabled", - "CheckTitle": "Ensure AuditDisabled organizationally is set to False.", + "CheckTitle": "Organization has mailbox auditing enabled", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Organization Configuration", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that the AuditDisabled property is set to False at the organizational level in Exchange Online. This enables mailbox auditing by default for all mailboxes and overrides individual mailbox settings.", - "Risk": "If mailbox auditing is disabled at the organization level, no mailbox actions are audited, limiting forensic investigation capabilities and exposing the organization to undetected malicious activity.", - "RelatedUrl": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide", + "Description": "**Microsoft 365 Exchange Online** organization setting `AuditDisabled` controls tenant-wide **mailbox auditing**. This evaluates whether it is `False` so default audit events are recorded for owner, delegate, and admin across all mailboxes, taking precedence over per-mailbox settings.", + "Risk": "Disabling tenant-wide auditing lets mailbox activity go unrecorded. Adversaries or insiders could exfiltrate data, alter or delete messages, or send as users without trace, undermining **confidentiality**, **integrity**, and effective **incident response**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://o365reports.com/2020/01/21/enable-mailbox-auditing-in-office-365-powershell/", + "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", + "https://techcommunity.microsoft.com/blog/microsoft-security-blog/exchange-online-mailbox-auditing-enabled-by-default/361324" + ], "Remediation": { "Code": { "CLI": "Set-OrganizationConfig -AuditDisabled $false", "NativeIaC": "", - "Other": "", + "Other": "1. Open PowerShell and connect to Exchange Online: Connect-ExchangeOnline\n2. Run: Set-OrganizationConfig -AuditDisabled $false\n3. Verify: Get-OrganizationConfig | Select-Object AuditDisabled (should be False)", "Terraform": "" }, "Recommendation": { - "Text": "Set AuditDisabled to False at the organization level to ensure mailbox auditing is always enforced.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-organizationconfig?view=exchange-ps#-auditdisabled" + "Text": "Ensure `AuditDisabled`=`False` to keep **mailbox auditing** on by default.\n\n- Apply **least privilege** and minimize audit bypass\n- Define retention and review audit logs\n- Alert on risky actions (e.g., hard delete, rule changes)\n- Layer with **defense in depth** for email access", + "Url": "https://hub.prowler.com/check/exchange_organization_mailbox_auditing_enabled" } }, "Categories": [ + "logging", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_organization_mailtips_enabled/exchange_organization_mailtips_enabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_organization_mailtips_enabled/exchange_organization_mailtips_enabled.metadata.json index 95ebf223c3..0438f8e5fb 100644 --- a/prowler/providers/m365/services/exchange/exchange_organization_mailtips_enabled/exchange_organization_mailtips_enabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_organization_mailtips_enabled/exchange_organization_mailtips_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "exchange_organization_mailtips_enabled", - "CheckTitle": "Ensure MailTips are enabled for end users.", + "CheckTitle": "Organization has MailTips fully enabled", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Exchange Organization Configuration", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that MailTips are enabled in Exchange Online to provide users with informative messages while composing emails, helping to avoid issues such as sending to large groups or external recipients unintentionally.", - "Risk": "Without MailTips, users may inadvertently send sensitive information externally or generate non-delivery reports, leading to communication errors and potential data exposure.", - "RelatedUrl": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/mailtips/mailtips", + "Description": "**Microsoft 365 Exchange Online** organization has **MailTips** fully configured: `MailTipsAllTipsEnabled`, `MailTipsExternalRecipientsTipsEnabled`, `MailTipsGroupMetricsEnabled`, and `MailTipsLargeAudienceThreshold` `25`.", + "Risk": "Absent or lax **MailTips** reduces user cues, increasing unintended external sends and large-audience blasts, harming **confidentiality**. Missing group metrics or high thresholds hide risky recipient counts; no OOF/full-mailbox tips cause misdelivery that enables phishing loops and data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/set-organizationconfig?view=exchange-ps", + "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/mailtips/mailtips" + ], "Remediation": { "Code": { - "CLI": "$TipsParams = @{ MailTipsAllTipsEnabled = $true; MailTipsExternalRecipientsTipsEnabled = $true; MailTipsGroupMetricsEnabled = $true; MailTipsLargeAudienceThreshold = '25' }; Set-OrganizationConfig @TipsParams", + "CLI": "Set-OrganizationConfig -MailTipsAllTipsEnabled $true -MailTipsExternalRecipientsTipsEnabled $true -MailTipsGroupMetricsEnabled $true -MailTipsLargeAudienceThreshold 25", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to the Exchange admin center (admin.exchange.microsoft.com)\n2. Open Classic Exchange admin center > Organization > MailTips\n3. Enable: \"Enable MailTips\" (All tips)\n4. Enable: \"External recipients MailTip\"\n5. Enable: \"Turn on group metrics for MailTips\"\n6. Set \"Large audience threshold\" to 25 (or less)\n7. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable MailTips features in Exchange Online and configure the large audience threshold appropriately to assist users when composing emails.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/exchange/set-organizationconfig?view=exchange-ps" + "Text": "Apply **defense in depth** with consistent **MailTips**:\n- Enable external-recipient and group-metrics tips\n- Keep `MailTipsLargeAudienceThreshold` conservative (`25`)\n- Train users to heed tips before sending\nPair with **DLP** and restricted forwarding to prevent accidental disclosure.", + "Url": "https://hub.prowler.com/check/exchange_organization_mailtips_enabled" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_organization_modern_authentication_enabled/exchange_organization_modern_authentication_enabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_organization_modern_authentication_enabled/exchange_organization_modern_authentication_enabled.metadata.json index ea53183762..e98cf702c8 100644 --- a/prowler/providers/m365/services/exchange/exchange_organization_modern_authentication_enabled/exchange_organization_modern_authentication_enabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_organization_modern_authentication_enabled/exchange_organization_modern_authentication_enabled.metadata.json @@ -1,31 +1,35 @@ { "Provider": "m365", "CheckID": "exchange_organization_modern_authentication_enabled", - "CheckTitle": "Ensure Modern Authentication for Exchange Online is enabled.", + "CheckTitle": "Organization has Modern Authentication enabled", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Exchange Organization Configuration", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that modern authentication is enabled for Exchange Online, requiring exchange and mailboxes clients to use strong authentication mechanisms instead of basic authentication.", - "Risk": "If modern authentication is not enabled, Exchange Online email clients may fall back to basic authentication, making it easier for attackers to bypass multifactor authentication and compromise user credentials.", - "RelatedUrl": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/enable-or-disable-modern-authentication-in-exchange-online", + "Description": "**Microsoft 365 Exchange Online** organization setting determines if **Modern Authentication** (`OAuth 2.0`) is enabled for client connections.\n\nThis evaluates whether clients use token-based sign-in rather than `Basic` credentials.", + "Risk": "Without **Modern Authentication**, clients may fall back to `Basic`, disabling **MFA** and enabling **password spraying** and **credential stuffing**. Account takeover can expose mailboxes, alter rules, and send fraudulent emails, harming confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/enable-or-disable-modern-authentication-in-exchange-online", + "https://profadmins.com/2020/03/27/enabling-modern-authentication-and-mfa/" + ], "Remediation": { "Code": { - "CLI": "Set-OrganizationConfig -OAuth2ClientProfileEnabled $True", + "CLI": "Set-OrganizationConfig -OAuth2ClientProfileEnabled $true", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to the Microsoft 365 admin center\n2. Go to Settings > Org settings > Modern authentication\n3. Enable \"Turn on modern authentication for Outlook 2013 for Windows and later\"\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable modern authentication in Exchange Online to enforce secure authentication methods for email clients.", - "Url": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/enable-or-disable-modern-authentication-in-exchange-online" + "Text": "Enable **Modern Authentication** org-wide and phase out `Basic` to enforce token-based access. Require **MFA** with conditional access, block legacy mail protocols where feasible, and apply **least privilege** on mailbox permissions. Monitor sign-ins for legacy usage to maintain **defense in depth**.", + "Url": "https://hub.prowler.com/check/exchange_organization_modern_authentication_enabled" } }, "Categories": [ - "e3" + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/m365/services/exchange/exchange_roles_assignment_policy_addins_disabled/exchange_roles_assignment_policy_addins_disabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_roles_assignment_policy_addins_disabled/exchange_roles_assignment_policy_addins_disabled.metadata.json index d63ad787ab..e0f6633efc 100644 --- a/prowler/providers/m365/services/exchange/exchange_roles_assignment_policy_addins_disabled/exchange_roles_assignment_policy_addins_disabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_roles_assignment_policy_addins_disabled/exchange_roles_assignment_policy_addins_disabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "exchange_roles_assignment_policy_addins_disabled", - "CheckTitle": "Ensure there is no policy with Outlook add-ins allowed.", + "CheckTitle": "Role assignment policy does not allow Outlook add-ins", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Role Assignment Policy", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Restricting users from installing Outlook add-ins reduces the risk of data exposure or exploitation through unapproved or vulnerable add-ins.", - "Risk": "Allowing users to install add-ins may expose sensitive information or introduce malicious behavior through third-party integrations. Disabling this capability mitigates the risk of unauthorized data access.", - "RelatedUrl": "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", + "Description": "**Microsoft 365 Exchange Online role assignment policies** are assessed for roles that permit installing or managing **Outlook add-ins** (such as `My Marketplace Apps`, `My Custom Apps`, `My ReadWriteMailbox Apps`, `Org Marketplace Apps`, `Org Custom Apps`). Presence of these roles indicates users or admins can deploy add-ins from the store or custom sources.", + "Risk": "Allowing add-in installation exposes mailboxes to **malicious or vulnerable add-ins**. With `ReadWriteMailbox`, an add-in can read, copy, or alter messages, auto-forward mail, and access tokens, enabling **data exfiltration**, message tampering, and **lateral movement**, impacting confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "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", + "https://learn.microsoft.com/en-us/exchange/permissions-exo/role-assignment-policies" + ], "Remediation": { "Code": { - "CLI": "$policy = \"Role Assignment Policy - Prevent Add-ins\"; $roles = \"MyTextMessaging\", \"MyDistributionGroups\", \"MyMailSubscriptions\", \"MyBaseOptions\", \"MyVoiceMail\", \"MyProfileInformation\", \"MyContactInformation\", \"MyRetentionPolicies\", \"MyDistributionGroupMembership\"; New-RoleAssignmentPolicy -Name $policy -Roles $roles; Set-RoleAssignmentPolicy -id $policy -IsDefault; Get-EXOMailbox -ResultSize Unlimited | Set-Mailbox -RoleAssignmentPolicy $policy", + "CLI": "Get-ManagementRoleAssignment -RoleAssigneeType RoleAssignmentPolicy | Where-Object {$_.Name -like \"My Custom Apps-*\" -or $_.Name -like \"My Marketplace Apps-*\" -or $_.Name -like \"My ReadWriteMailbox Apps-*\"} | Remove-ManagementRoleAssignment -Confirm:$false", "NativeIaC": "", - "Other": "1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Click to expand Roles > User roles. 3. Select Default Role Assignment Policy. 4. In the right pane, click Manage permissions. 5. Uncheck My Custom Apps, My Marketplace Apps and My ReadWriteMailboxApps under Other roles. 6. Save changes.", + "Other": "1. Sign in to the Exchange admin center: https://admin.exchange.microsoft.com\n2. Go to Roles > User roles\n3. For each role assignment policy:\n - Select the policy > Manage permissions\n - Under Other roles, uncheck: My Custom Apps, My Marketplace Apps, My ReadWriteMailbox Apps\n - Save changes\n4. Repeat for all role assignment policies so none include these three roles", "Terraform": "" }, "Recommendation": { - "Text": "Restrict Outlook add-in installation by updating the Role Assignment Policy to exclude roles that allow app installation.", - "Url": "https://learn.microsoft.com/en-us/exchange/permissions-exo/role-assignment-policies" + "Text": "Enforce **least privilege** for add-ins: remove add-in roles from end-user policies, reserve add-in management for trusted admins, and only deploy vetted add-ins via an **allowlist**. Review role assignment policies regularly to sustain **defense in depth** and prevent unapproved extensions.", + "Url": "https://hub.prowler.com/check/exchange_roles_assignment_policy_addins_disabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.metadata.json index 118868c158..71ef7efc0a 100644 --- a/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_shared_mailbox_sign_in_disabled/exchange_shared_mailbox_sign_in_disabled.metadata.json @@ -7,9 +7,9 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Shared Mailbox", + "ResourceType": "NotDefined", "ResourceGroup": "IAM", - "Description": "Shared mailboxes are used for collaboration and should not permit direct sign-in. This check verifies that the **AccountEnabled** property is set to `false` in Entra ID for all shared mailboxes, preventing direct authentication.", + "Description": "**Microsoft 365 Shared mailboxes** are used for collaboration and should not permit direct sign-in. This check verifies that the **AccountEnabled** property is set to `false` in Entra ID for all shared mailboxes, preventing direct authentication.", "Risk": "When sign-in is enabled on shared mailboxes, users with the password can bypass delegation controls and access the mailbox directly. This undermines **accountability** since actions cannot be attributed to individual users, and it increases the attack surface for credential-based attacks.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/m365/services/exchange/exchange_transport_config_smtp_auth_disabled/exchange_transport_config_smtp_auth_disabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_transport_config_smtp_auth_disabled/exchange_transport_config_smtp_auth_disabled.metadata.json index a39c9c23ca..81649fd8eb 100644 --- a/prowler/providers/m365/services/exchange/exchange_transport_config_smtp_auth_disabled/exchange_transport_config_smtp_auth_disabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_transport_config_smtp_auth_disabled/exchange_transport_config_smtp_auth_disabled.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "exchange_transport_config_smtp_auth_disabled", - "CheckTitle": "Ensure SMTP AUTH is disabled.", + "CheckTitle": "SMTP AUTH is disabled in the Exchange Online Transport Configuration", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Transport Config", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that SMTP AUTH is disabled at the organization level in Exchange Online to reduce exposure to legacy protocols that can be exploited for malicious use.", - "Risk": "Leaving SMTP AUTH enabled allows legacy clients to authenticate using outdated methods, increasing the risk of credential compromise and unauthorized email sending.", - "RelatedUrl": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission", + "Description": "**Microsoft 365 Exchange Online transport configuration** disables **authenticated SMTP submission** (`SMTP AUTH`) at the organization level", + "Risk": "With **SMTP AUTH enabled**, attackers can:\n- Launch **password spraying** against mailboxes\n- Bypass **MFA** on SMTP submissions\n- Send **unauthorized email**, enabling internal spoofing and phishing\n\nThis undermines message **integrity**, aids **lateral movement**, and harms tenant reputation and deliverability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission" + ], "Remediation": { "Code": { "CLI": "Set-TransportConfig -SmtpClientAuthenticationDisabled $true", "NativeIaC": "", - "Other": "1. Navigate to Exchange admin center https://admin.exchange.microsoft.com. 2. Select Settings > Mail flow. 3. Ensure 'Turn off SMTP AUTH protocol for your organization' is checked.", + "Other": "1. Open the Exchange admin center: https://admin.exchange.microsoft.com\n2. Go to Settings > Mail flow\n3. Turn on \"Turn off SMTP AUTH protocol for your organization\"\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable SMTP AUTH at the organization level to support secure, modern authentication practices and block legacy protocol usage.", - "Url": "https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/authenticated-client-smtp-submission" + "Text": "Disable **SMTP AUTH** tenant-wide and allow per-mailbox exceptions only when justified, time-bound, and monitored. Prefer **modern authentication** and secure submission alternatives. Apply **least privilege** and **defense in depth**, restrict app access, rotate secrets, and monitor send patterns for anomalies.", + "Url": "https://hub.prowler.com/check/exchange_transport_config_smtp_auth_disabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_transport_rules_mail_forwarding_disabled/exchange_transport_rules_mail_forwarding_disabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_transport_rules_mail_forwarding_disabled/exchange_transport_rules_mail_forwarding_disabled.metadata.json index d717e8f31b..e319d2cdd8 100644 --- a/prowler/providers/m365/services/exchange/exchange_transport_rules_mail_forwarding_disabled/exchange_transport_rules_mail_forwarding_disabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_transport_rules_mail_forwarding_disabled/exchange_transport_rules_mail_forwarding_disabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "exchange_transport_rules_mail_forwarding_disabled", - "CheckTitle": "Ensure mail transport rules are set to disable mail forwarding.", + "CheckTitle": "Transport rule does not allow forwarding mail to external domains", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Transport Rules", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure mail transport rules are set to disable mail forwarding.", - "Risk": "Enabling email auto-forwarding can be exploited by attackers or malicious insiders to exfiltrate sensitive data outside the organization, often without detection.", - "RelatedUrl": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/configuration-best-practices", + "Description": "**Microsoft 365 Exchange Online mail flow rules** are assessed for actions that **forward or redirect messages to external domains**. The finding highlights rules that add external recipients during transport.", + "Risk": "External auto-forwarding enables silent **data exfiltration**, bypassing **DLP** and retention, reducing **confidentiality**.\n\nA compromised mailbox can use forwarding for **persistence** and **lateral movement**, leaking sensitive content to untrusted domains and undermining communication **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules", + "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/configuration-best-practices" + ], "Remediation": { "Code": { - "CLI": "Remove-TransportRule -Identity ", + "CLI": "Remove-TransportRule -Identity ", "NativeIaC": "", - "Other": "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.", + "Other": "1. In the Microsoft 365 admin center, go to Admin centers > Exchange\n2. Navigate to Mail flow > Rules\n3. For each rule that has the action \"Redirect the message to\", select the rule, click Edit\n4. Under \"Do the following\", remove the action \"Redirect the message to\"\n5. Click Save\n6. Repeat for any other rules with this action", "Terraform": "" }, "Recommendation": { - "Text": "Block all forms of mail forwarding using Transport rules in Exchange Online. Apply exclusions only where justified by organizational policy.", - "Url": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules" + "Text": "Block external auto-forwarding at the organization level and prefer internal controls for sharing. Apply **least privilege** with narrowly scoped, time-bound exceptions only when justified.\n\nAdopt **defense in depth**: pair with DLP, outbound filtering, and alerts on new forwarding rules. Review and attest rules regularly.", + "Url": "https://hub.prowler.com/check/exchange_transport_rules_mail_forwarding_disabled" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_transport_rules_whitelist_disabled/exchange_transport_rules_whitelist_disabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_transport_rules_whitelist_disabled/exchange_transport_rules_whitelist_disabled.metadata.json index c7e0f580db..7cd0121c9b 100644 --- a/prowler/providers/m365/services/exchange/exchange_transport_rules_whitelist_disabled/exchange_transport_rules_whitelist_disabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_transport_rules_whitelist_disabled/exchange_transport_rules_whitelist_disabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "exchange_transport_rules_whitelist_disabled", - "CheckTitle": "Ensure mail transport rules do not whitelist specific domains", + "CheckTitle": "Transport rule does not whitelist any domains", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Transport Rules", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Mail flow rules (transport rules) in Exchange Online are used to identify and take action on messages that flow through the organization.", - "Risk": "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.", - "RelatedUrl": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/configuration-best-practices", + "Description": "**Microsoft 365 Exchange Online mail flow rules** that whitelist specific sender domains by forcing `SCL` to `-1` (skip spam filtering) on matching messages", + "Risk": "**Domain-based whitelisting** skips **anti-spam/phish** analysis, allowing spoofed or compromised senders to reach the Inbox. This increases targeted phishing, BEC, and credential theft, enabling unauthorized access and data exfiltration, degrading **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/use-rules-to-set-scl", + "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" + ], "Remediation": { "Code": { - "CLI": "Remove-TransportRule -Identity ", + "CLI": "Set-TransportRule -Identity -SetSCL 0", "NativeIaC": "", - "Other": "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 whitelists specific domains, select the rule and click the 'Delete' icon.", + "Other": "1. Open the Exchange admin center: https://admin.exchange.microsoft.com\n2. Go to Mail flow > Rules\n3. Edit any rule that has: condition \"The sender domain is\" AND action \"Set the spam confidence level (SCL) = Bypass spam filtering\"\n4. In Do the following, change \"Set the spam confidence level (SCL)\" from Bypass spam filtering to 0 (or remove the action)\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Remove transport rules that whitelist specific domains to ensure proper scanning.", - "Url": "https://learn.microsoft.com/en-us/exchange/security-and-compliance/mail-flow-rules/mail-flow-rules" + "Text": "Avoid blanket whitelisting. Do not set `SCL` to `-1` based solely on `sender domain`.\n\n- Prefer controlled allow mechanisms with review/expiry; keep **anti-spam/phish** active\n- If exceptions are unavoidable, apply **least privilege**: add strong conditions (auth results, known source IPs), narrow scope, time-bound, and monitor", + "Url": "https://hub.prowler.com/check/exchange_transport_rules_whitelist_disabled" } }, "Categories": [ + "email-security", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/exchange/exchange_user_mailbox_auditing_enabled/exchange_user_mailbox_auditing_enabled.metadata.json b/prowler/providers/m365/services/exchange/exchange_user_mailbox_auditing_enabled/exchange_user_mailbox_auditing_enabled.metadata.json index 227d0e4fd2..1529d870b1 100644 --- a/prowler/providers/m365/services/exchange/exchange_user_mailbox_auditing_enabled/exchange_user_mailbox_auditing_enabled.metadata.json +++ b/prowler/providers/m365/services/exchange/exchange_user_mailbox_auditing_enabled/exchange_user_mailbox_auditing_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "exchange_user_mailbox_auditing_enabled", - "CheckTitle": "Ensure mailbox auditing is enabled for all user mailboxes.", + "CheckTitle": "User mailbox auditing is enabled with required Admin, Delegate, and Owner actions and audit log age meets the minimum", "CheckType": [], "ServiceName": "exchange", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Exchange Mailboxes Properties", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure mailbox auditing is enabled for all user mailboxes, including the configuration of audit actions for owners, delegates, and admins beyond the Microsoft defaults. The difference between both subscription is the log age so this parameter is configurable and users can set it to their subscription needs.", - "Risk": "If auditing is not properly enabled and configured, critical mailbox actions may go unrecorded, reducing the ability to investigate incidents, enforce compliance, or detect malicious behavior.", - "RelatedUrl": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide", + "Description": "**Microsoft 365 Exchange Online user mailboxes** have auditing enabled, include a defined set of actions for **Owner**, **Delegate**, and **Admin**, and retain audit records for at least the configured baseline (default `90` days).", + "Risk": "**Incomplete or short-retained mailbox audits** degrade confidentiality and integrity.\n- Untracked `SendAs`, inbox rule changes, and deletions enable covert access and data loss\n- Narrow log windows create blind spots, delaying detection and hindering forensics and eDiscovery", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide", + "https://o365info.com/mailbox-audit-powershell-microsoft-365/" + ], "Remediation": { "Code": { - "CLI": "$AuditAdmin = @(\"ApplyRecord\", \"Copy\", \"Create\", \"FolderBind\", \"HardDelete\", \"Move\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\"); $AuditDelegate = @(\"ApplyRecord\", \"Create\", \"FolderBind\", \"HardDelete\", \"Move\", \"MoveToDeletedItems\", \"SendAs\", \"SendOnBehalf\", \"SoftDelete\", \"Update\", \"UpdateFolderPermissions\", \"UpdateInboxRules\"); $AuditOwner = @(\"ApplyRecord\", \"Create\", \"HardDelete\", \"MailboxLogin\", \"Move\", \"MoveToDeletedItems\", \"SoftDelete\", \"Update\", \"UpdateCalendarDelegation\", \"UpdateFolderPermissions\", \"UpdateInboxRules\"); $MBX = Get-EXOMailbox -ResultSize Unlimited | Where-Object { $_.RecipientTypeDetails -eq \"UserMailbox\" }; $MBX | Set-Mailbox -AuditEnabled $true -AuditLogAgeLimit 90 -AuditAdmin $AuditAdmin -AuditDelegate $AuditDelegate -AuditOwner $AuditOwner", + "CLI": "Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox | Set-Mailbox -AuditEnabled $true -AuditLogAgeLimit 90 -AuditAdmin @('ApplyRecord','Copy','Create','FolderBind','HardDelete','Move','MoveToDeletedItems','SendAs','SendOnBehalf','SoftDelete','Update','UpdateCalendarDelegation','UpdateFolderPermissions','UpdateInboxRules') -AuditDelegate @('ApplyRecord','Create','FolderBind','HardDelete','Move','MoveToDeletedItems','SendAs','SendOnBehalf','SoftDelete','Update','UpdateFolderPermissions','UpdateInboxRules') -AuditOwner @('ApplyRecord','Create','HardDelete','MailboxLogin','Move','MoveToDeletedItems','SoftDelete','Update','UpdateCalendarDelegation','UpdateFolderPermissions','UpdateInboxRules')", "NativeIaC": "", "Other": "", "Terraform": "" }, "Recommendation": { - "Text": "Enable mailbox auditing for all user mailboxes and configure auditing for key mailbox actions for owners, delegates, and admins.", - "Url": "https://learn.microsoft.com/en-us/purview/audit-mailboxes?view=o365-worldwide" + "Text": "Standardize mailbox auditing on all user mailboxes. Log critical actions for **Owner**, **Delegate**, and **Admin** (e.g., `SendAs`, `UpdateInboxRules`, `HardDelete`, `MailboxLogin`). Set `AuditLogAgeLimit` to meet policy ( baseline). Apply least privilege for delegates, avoid audit bypass, and regularly review or forward logs to monitoring.", + "Url": "https://hub.prowler.com/check/exchange_user_mailbox_auditing_enabled" } }, "Categories": [ + "logging", "e3", "e5" ], diff --git a/prowler/providers/m365/services/intune/__init__.py b/prowler/providers/m365/services/intune/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/intune/intune_client.py b/prowler/providers/m365/services/intune/intune_client.py new file mode 100644 index 0000000000..89a9d580d2 --- /dev/null +++ b/prowler/providers/m365/services/intune/intune_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.m365.services.intune.intune_service import Intune + +intune_client = Intune(Provider.get_global_provider()) diff --git a/prowler/providers/m365/services/intune/intune_service.py b/prowler/providers/m365/services/intune/intune_service.py new file mode 100644 index 0000000000..4b62f64e99 --- /dev/null +++ b/prowler/providers/m365/services/intune/intune_service.py @@ -0,0 +1,315 @@ +import asyncio +from typing import Optional + +from kiota_abstractions.base_request_configuration import RequestConfiguration +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.m365.lib.service.service import M365Service +from prowler.providers.m365.m365_provider import M365Provider + + +class Intune(M365Service): + """Microsoft Intune service class.""" + + MDM_MANAGEMENT_AGENTS = { + "mdm", + "easMdm", + "intuneClient", + "easIntuneClient", + "configurationManagerClientMdm", + "configurationManagerClientMdmEas", + "microsoft365ManagedMdm", + } + + def __init__(self, provider: M365Provider): + super().__init__(provider) + + self.tenant_domain = provider.identity.tenant_domain + self.settings: Optional[IntuneSettings] = None + self.compliance_policies: Optional[list[IntuneCompliancePolicy]] = [] + self.managed_devices: Optional[list[IntuneManagedDevice]] = [] + self.verification_error: Optional[str] = None + + loop = self._get_event_loop() + try: + ( + settings, + settings_error, + policies, + policies_error, + managed_devices, + managed_devices_error, + ) = loop.run_until_complete(self._load_intune_configuration()) + self.settings = settings + self.compliance_policies = policies + self.managed_devices = managed_devices + self.verification_error = ( + " ".join( + error + for error in [ + settings_error, + policies_error, + managed_devices_error, + ] + if error + ) + or None + ) + finally: + self._cleanup_event_loop(loop) + + def _get_event_loop(self) -> asyncio.AbstractEventLoop: + """Get or create an event loop for async operations.""" + try: + loop = asyncio.get_running_loop() + if loop.is_running(): + raise RuntimeError( + "Cannot initialize Intune service while event loop is running" + ) + return loop + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + def _cleanup_event_loop(self, loop: asyncio.AbstractEventLoop) -> None: + """Clean up the event loop if we created it.""" + try: + if loop and not loop.is_running(): + asyncio.set_event_loop(None) + loop.close() + except Exception as error: + logger.debug(f"Intune - Failed to clean up event loop: {error}") + + async def _load_intune_configuration( + self, + ) -> tuple[ + Optional["IntuneSettings"], + Optional[str], + Optional[list["IntuneCompliancePolicy"]], + Optional[str], + Optional[list["IntuneManagedDevice"]], + Optional[str], + ]: + settings, settings_error = await self._get_settings() + policies, policies_error = await self._get_compliance_policies() + managed_devices, managed_devices_error = await self._get_managed_devices() + return ( + settings, + settings_error, + policies, + policies_error, + managed_devices, + managed_devices_error, + ) + + async def _get_settings(self) -> tuple[Optional["IntuneSettings"], Optional[str]]: + """Retrieve Intune tenant settings required for compliance evaluation.""" + logger.info("Intune - Getting device management settings...") + + try: + from msgraph.generated.device_management.device_management_request_builder import ( + DeviceManagementRequestBuilder, + ) + + query_parameters = ( + DeviceManagementRequestBuilder.DeviceManagementRequestBuilderGetQueryParameters() + ) + query_parameters.select = ["settings"] + request_configuration = RequestConfiguration( + query_parameters=query_parameters + ) + + device_management = await self.client.device_management.get( + request_configuration=request_configuration + ) + settings = getattr(device_management, "settings", None) + if settings is None: + return ( + IntuneSettings(secure_by_default=None), + None, + ) + + return ( + IntuneSettings( + secure_by_default=getattr(settings, "secure_by_default", None) + ), + None, + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return ( + None, + "Could not read Microsoft Intune device management settings. Ensure the Service Principal has DeviceManagementServiceConfig.Read.All permission granted.", + ) + + async def _get_compliance_policies( + self, + ) -> tuple[Optional[list["IntuneCompliancePolicy"]], Optional[str]]: + """Retrieve Intune device compliance policies and their assignments.""" + logger.info("Intune - Getting device compliance policies...") + policies: list[IntuneCompliancePolicy] = [] + + try: + response = ( + await self.client.device_management.device_compliance_policies.get() + ) + + while response: + for policy in getattr(response, "value", []) or []: + assignment_count, assignment_error = ( + await self._get_assignment_count(getattr(policy, "id", "")) + ) + if assignment_error: + return None, assignment_error + + policies.append( + IntuneCompliancePolicy( + id=getattr(policy, "id", ""), + display_name=getattr(policy, "display_name", ""), + assignment_count=assignment_count, + ) + ) + + next_link = getattr(response, "odata_next_link", None) + if not next_link: + break + response = await self.client.device_management.device_compliance_policies.with_url( + next_link + ).get() + + return policies, None + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return ( + None, + "Could not read Microsoft Intune device compliance policies. Ensure the Service Principal has DeviceManagementConfiguration.Read.All permission granted.", + ) + + async def _get_assignment_count(self, policy_id: str) -> tuple[int, Optional[str]]: + """Count assignments for a single Intune device compliance policy.""" + if not policy_id: + return 0, None + + try: + assignments_response = await self.client.device_management.device_compliance_policies.by_device_compliance_policy_id( + policy_id + ).assignments.get() + + assignment_count = 0 + while assignments_response: + assignment_count += len( + getattr(assignments_response, "value", []) or [] + ) + next_link = getattr(assignments_response, "odata_next_link", None) + if not next_link: + break + assignments_response = ( + await self.client.device_management.device_compliance_policies.by_device_compliance_policy_id( + policy_id + ) + .assignments.with_url(next_link) + .get() + ) + + return assignment_count, None + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return ( + 0, + "Could not read Microsoft Intune device compliance policy assignments. Ensure the Service Principal has DeviceManagementConfiguration.Read.All permission granted.", + ) + + async def _get_managed_devices( + self, + ) -> tuple[Optional[list["IntuneManagedDevice"]], Optional[str]]: + """Retrieve Intune managed devices needed for operational evidence.""" + logger.info("Intune - Getting managed devices...") + managed_devices: list[IntuneManagedDevice] = [] + + try: + from msgraph.generated.device_management.managed_devices.managed_devices_request_builder import ( + ManagedDevicesRequestBuilder, + ) + + query_parameters = ( + ManagedDevicesRequestBuilder.ManagedDevicesRequestBuilderGetQueryParameters() + ) + query_parameters.select = [ + "id", + "deviceName", + "complianceState", + "managementAgent", + ] + request_configuration = RequestConfiguration( + query_parameters=query_parameters + ) + + response = await self.client.device_management.managed_devices.get( + request_configuration=request_configuration + ) + + while response: + for device in getattr(response, "value", []) or []: + managed_devices.append( + IntuneManagedDevice( + id=getattr(device, "id", ""), + device_name=getattr(device, "device_name", ""), + compliance_state=( + str(getattr(device, "compliance_state", "")) + if getattr(device, "compliance_state", None) + else "" + ), + management_agent=( + str(getattr(device, "management_agent", "")) + if getattr(device, "management_agent", None) + else "" + ), + ) + ) + + next_link = getattr(response, "odata_next_link", None) + if not next_link: + break + response = await self.client.device_management.managed_devices.with_url( + next_link + ).get() + + return managed_devices, None + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return ( + None, + "Could not read Microsoft Intune managed devices. Ensure the Service Principal has DeviceManagementManagedDevices.Read.All permission granted.", + ) + + @classmethod + def is_mdm_managed_device(cls, management_agent: str) -> bool: + """Return whether a management agent represents MDM or Intune management.""" + return management_agent in cls.MDM_MANAGEMENT_AGENTS + + +class IntuneSettings(BaseModel): + secure_by_default: Optional[bool] = None + + +class IntuneCompliancePolicy(BaseModel): + id: str + display_name: str + assignment_count: int = 0 + + +class IntuneManagedDevice(BaseModel): + id: str + device_name: str = "" + compliance_state: str = "" + management_agent: str = "" diff --git a/prowler/providers/m365/services/purview/purview_audit_log_search_enabled/purview_audit_log_search_enabled.metadata.json b/prowler/providers/m365/services/purview/purview_audit_log_search_enabled/purview_audit_log_search_enabled.metadata.json index 6a7f056fe5..68e458ee49 100644 --- a/prowler/providers/m365/services/purview/purview_audit_log_search_enabled/purview_audit_log_search_enabled.metadata.json +++ b/prowler/providers/m365/services/purview/purview_audit_log_search_enabled/purview_audit_log_search_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "purview_audit_log_search_enabled", - "CheckTitle": "Ensure Purview audit log search is enabled", + "CheckTitle": "Purview audit log search is enabled", "CheckType": [], "ServiceName": "purview", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Purview Settings", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure Purview audit log search is enabled.", - "Risk": "Disabling Microsoft 365 audit log search can hinder the ability to track and monitor user and admin activities, making it harder to detect suspicious behavior, security incidents, or compliance violations. This can result in undetected breaches and inability to respond to incidents effectively.", - "RelatedUrl": "https://learn.microsoft.com/en-us/purview/audit-search?tabs=microsoft-purview-portal", + "Description": "Microsoft Purview tenant setting for **audit log search** is assessed to confirm unified audit log ingestion (`UnifiedAuditLogIngestionEnabled`), which records user and admin activities and makes them searchable.", + "Risk": "Without **audit log ingestion/search**, activity trails are missing or delayed, reducing visibility and accountability.\n- Data exfiltration and privilege abuse go undetected (confidentiality/integrity)\n- Incident response and forensics fail due to absent evidence, increasing dwell time", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/purview/audit-search?tabs=microsoft-purview-portal", + "https://learn.microsoft.com/en-us/purview/audit-log-enable-disable" + ], "Remediation": { "Code": { "CLI": "Set-AdminAuditLogConfig -UnifiedAuditLogIngestionEnabled $true", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Purview https://compliance.microsoft.com. 2. Select Audit to open the audit search. 3. Click Start recording user and admin activity next to the information warning at the top. 4. Click Yes on the dialog box to confirm.", + "Other": "1. Go to https://compliance.microsoft.com and sign in with an admin account\n2. Open Solutions > Audit\n3. Click Start recording user and admin activity\n4. Click Yes to confirm", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that Microsoft 365 audit log search is enabled to maintain a comprehensive record of user and admin activities. This will help improve security monitoring, support compliance needs, and provide critical insights for responding to incidents.", - "Url": "https://learn.microsoft.com/en-us/purview/audit-search?tabs=microsoft-purview-portal" + "Text": "Enable and keep **audit log search** on (`UnifiedAuditLogIngestionEnabled=true`). Apply **least privilege** to audit roles, set retention aligned to sensitivity, forward logs to a SIEM for **defense in depth**, and routinely review and alert on audit events. *Avoid disabling auditing even when using third-party tools.*", + "Url": "https://hub.prowler.com/check/purview_audit_log_search_enabled" } }, "Categories": [ + "logging", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/sharepoint/sharepoint_external_sharing_managed/sharepoint_external_sharing_managed.metadata.json b/prowler/providers/m365/services/sharepoint/sharepoint_external_sharing_managed/sharepoint_external_sharing_managed.metadata.json index ba76b044ae..cf4b98eddb 100644 --- a/prowler/providers/m365/services/sharepoint/sharepoint_external_sharing_managed/sharepoint_external_sharing_managed.metadata.json +++ b/prowler/providers/m365/services/sharepoint/sharepoint_external_sharing_managed/sharepoint_external_sharing_managed.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "sharepoint_external_sharing_managed", - "CheckTitle": "Ensure SharePoint external sharing is managed through domain whitelists/blacklists.", + "CheckTitle": "External sharing is restricted using a non-empty domain allowlist or blocklist", "CheckType": [], "ServiceName": "sharepoint", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Sharepoint Settings", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Control the sharing of documents to external domains by either blocking specific domains or only allowing sharing with named trusted domains.", - "Risk": "If domain-based sharing restrictions are not enforced, users may share documents with untrusted external entities, increasing the risk of data exfiltration or unauthorized access.", - "RelatedUrl": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off", + "Description": "**Microsoft 365 SharePoint** external sharing uses **domain-based restrictions** via `AllowList` or `BlockList`. The evaluation inspects `sharingDomainRestrictionMode` and whether the corresponding domain list is populated, flagging when domain controls are missing or the selected list is empty.", + "Risk": "Without enforced domain limits, users may share with personal or rogue domains, enabling data exfiltration and unauthorized persistence.\n- Confidentiality: leaks of files and sites\n- Integrity: unvetted collaborators can alter content\n- Availability: takeovers can disrupt shared sites", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/sharepoint/restricted-domains-sharing", + "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off", + "https://blog.admindroid.com/restrict-domain-sharing-in-sharepoint-online-and-onedrive/" + ], "Remediation": { "Code": { - "CLI": "Set-SPOTenant -SharingDomainRestrictionMode AllowList -SharingAllowedDomainList 'domain1.com domain2.com'", + "CLI": "Set-SPOTenant -SharingDomainRestrictionMode AllowList -SharingAllowedDomainList 'contoso.com'", "NativeIaC": "", - "Other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Expand Policies then click Sharing. 3. Expand More external sharing settings and check 'Limit external sharing by domain'. 4. Select 'Add domains' to configure a list of approved domains. 5. Click Save.", + "Other": "1. In the SharePoint admin center, go to Policies > Sharing\n2. Expand More external sharing settings and check Limit external sharing by domain\n3. Click Add domains, select Allow only specific domains (or Block specific domains)\n4. Enter at least one domain (e.g., contoso.com) and click Save\n5. On the Sharing page, click Save to apply", "Terraform": "" }, "Recommendation": { - "Text": "Enforce domain-based restrictions for SharePoint external sharing to control document sharing with trusted domains.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + "Text": "Apply **least privilege** by enforcing domain-based sharing with a curated `AllowList` of trusted partners; use `BlockList` only to complement gaps.\n- Review and attest lists regularly\n- Use **defense in depth**: prefer authenticated, scoped links over public links and align with B2B governance and oversight.", + "Url": "https://hub.prowler.com/check/sharepoint_external_sharing_managed" } }, "Categories": [ + "internet-exposed", + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/sharepoint/sharepoint_external_sharing_restricted/sharepoint_external_sharing_restricted.metadata.json b/prowler/providers/m365/services/sharepoint/sharepoint_external_sharing_restricted/sharepoint_external_sharing_restricted.metadata.json index dfdd4aef00..e09a58a417 100644 --- a/prowler/providers/m365/services/sharepoint/sharepoint_external_sharing_restricted/sharepoint_external_sharing_restricted.metadata.json +++ b/prowler/providers/m365/services/sharepoint/sharepoint_external_sharing_restricted/sharepoint_external_sharing_restricted.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "sharepoint_external_sharing_restricted", - "CheckTitle": "Ensure external content sharing is restricted.", + "CheckTitle": "Organization external sharing is set to Existing guests only, New and existing guests, or Disabled", "CheckType": [], "ServiceName": "sharepoint", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Sharepoint Settings", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that external sharing settings in SharePoint are restricted to 'New and existing guests' or a less permissive level to enforce authentication and control over shared content.", - "Risk": "If external sharing is not restricted, unauthorized users may gain access to sensitive information, increasing the risk of data breaches and compliance violations.", - "RelatedUrl": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off", + "Description": "**Microsoft 365 SharePoint** org-wide external sharing is evaluated to ensure it excludes anonymous 'Anyone' links and is restricted to **authenticated guests** via `ExternalUserSharingOnly`, `ExistingExternalUserSharingOnly`, or fully `Disabled`.", + "Risk": "Anonymous or overly permissive sharing enables uncontrolled link access, eroding **confidentiality** and accountability. With edit links, **integrity** can be altered by unknown parties. Forwarded links and caching complicate revocation, increasing **data exfiltration** and long-lived exposure.", + "RelatedUrl": "", + "AdditionalURLs": [ + "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" + ], "Remediation": { "Code": { "CLI": "Set-SPOTenant -SharingCapability ExternalUserSharingOnly", "NativeIaC": "", - "Other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Click to 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.", + "Other": "1. Go to the Microsoft 365 admin center > Admin centers > SharePoint\n2. Navigate to Policies > Sharing\n3. Under External sharing for SharePoint, select New and existing guests (or a more restrictive option: Existing guests only or Only people in your organization)\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict external sharing in SharePoint to 'New and existing guests' or a more restrictive setting to enhance security.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + "Text": "Set org-level sharing to `ExternalUserSharingOnly` or stricter (`ExistingExternalUserSharingOnly`/`Disabled`). Apply **least privilege** with default links scoped to `SpecificPeople`, enforce Microsoft Entra B2B guest authentication, limit domains, require link expiration, block guest resharing, and monitor via audit logs.", + "Url": "https://hub.prowler.com/check/sharepoint_external_sharing_restricted" } }, "Categories": [ + "internet-exposed", + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/sharepoint/sharepoint_guest_sharing_restricted/sharepoint_guest_sharing_restricted.metadata.json b/prowler/providers/m365/services/sharepoint/sharepoint_guest_sharing_restricted/sharepoint_guest_sharing_restricted.metadata.json index 7a2836f7fc..fe77173c8f 100644 --- a/prowler/providers/m365/services/sharepoint/sharepoint_guest_sharing_restricted/sharepoint_guest_sharing_restricted.metadata.json +++ b/prowler/providers/m365/services/sharepoint/sharepoint_guest_sharing_restricted/sharepoint_guest_sharing_restricted.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "sharepoint_guest_sharing_restricted", - "CheckTitle": "Ensure that SharePoint guest users cannot share items they don't own.", + "CheckTitle": "Guest users cannot share items they do not own", "CheckType": [], "ServiceName": "sharepoint", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "Sharepoint Settings", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that guest users in SharePoint cannot share items they do not own, preventing unauthorized disclosure of shared content.", - "Risk": "If guest users are allowed to share items they don't own, there is a higher risk of unauthorized data exposure, as external users could share content beyond intended recipients.", - "RelatedUrl": "https://learn.microsoft.com/en-us/sharepoint/external-sharing-overview", + "Description": "**Microsoft 365 SharePoint** tenant sharing settings evaluate whether **guest resharing** is disabled (`resharingEnabled=false`).\n\nFocus is the org-level option that blocks guests from sharing items they don't own.", + "Risk": "Allowing **guest resharing** threatens confidentiality and integrity:\n- External users can extend access beyond oversight\n- Edit permissions enable unauthorized changes or deletion\n- Link sprawl reduces accountability and control\n\nSensitive data can spread across sites, hindering revocation and response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off", + "https://learn.microsoft.com/en-us/sharepoint/external-sharing-overview" + ], "Remediation": { "Code": { - "CLI": "Set-SPOTenant -PreventExternalUsersFromResharing $True", + "CLI": "Set-SPOTenant -PreventExternalUsersFromResharing $true", "NativeIaC": "", - "Other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Click to expand Policies then select Sharing. 3. Expand More external sharing settings and uncheck 'Allow guests to share items they don't own'. 4. Click Save.", + "Other": "1. Go to the SharePoint admin center: https://admin.microsoft.com/sharepoint\n2. Navigate to Policies > Sharing\n3. Expand More external sharing settings and uncheck \"Allow guests to share items they don't own\"\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict guest users from sharing items they don't own to enhance security and prevent unauthorized access.", - "Url": "https://learn.microsoft.com/en-us/sharepoint/turn-external-sharing-on-or-off" + "Text": "Disable **guest resharing** and apply **least privilege** so only owners or designated roles can share.\n\nLimit external sharing scope, require authenticated `Specific people` links with expirations, review guest access regularly, and monitor sharing activity to enforce **defense in depth**.", + "Url": "https://hub.prowler.com/check/sharepoint_guest_sharing_restricted" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/sharepoint/sharepoint_modern_authentication_required/sharepoint_modern_authentication_required.metadata.json b/prowler/providers/m365/services/sharepoint/sharepoint_modern_authentication_required/sharepoint_modern_authentication_required.metadata.json index f655cab021..b80048578e 100644 --- a/prowler/providers/m365/services/sharepoint/sharepoint_modern_authentication_required/sharepoint_modern_authentication_required.metadata.json +++ b/prowler/providers/m365/services/sharepoint/sharepoint_modern_authentication_required/sharepoint_modern_authentication_required.metadata.json @@ -1,31 +1,35 @@ { "Provider": "m365", "CheckID": "sharepoint_modern_authentication_required", - "CheckTitle": "Ensure modern authentication for SharePoint applications is required.", + "CheckTitle": "Requires modern authentication for applications", "CheckType": [], "ServiceName": "sharepoint", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Sharepoint Settings", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that modern authentication is required for SharePoint applications in Microsoft 365, preventing the use of legacy authentication protocols and blocking access to apps that don't use modern authentication.", - "Risk": "If modern authentication is not enforced, SharePoint applications may rely on basic authentication, which lacks strong security measures like MFA and increases the risk of credential theft.", - "RelatedUrl": "https://learn.microsoft.com/en-us/graph/api/resources/sharepoint?view=graph-rest-1.0", + "Description": "**Microsoft 365 SharePoint** tenant settings require **modern authentication** for applications and block access for apps using legacy protocols.\n\nThe assessment determines whether legacy authentication is disabled so only OAuth-based sign-ins with advanced controls are allowed.", + "Risk": "Without modern authentication, SharePoint is exposed to:\n- Password spraying and credential stuffing (no MFA)\n- Session/token capture and replay from basic auth\n- Unauthorized access leading to data exfiltration and tampering\n\nThis undermines data **confidentiality** and **integrity**, enabling lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/sharepoint?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + ], "Remediation": { "Code": { "CLI": "Set-SPOTenant -LegacyAuthProtocolsEnabled $false", "NativeIaC": "", - "Other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint. 2. Click to expand Policies select Access control. 3. Select Apps that don't use modern authentication. 4. Select the radio button for Block access. 5. Click Save.", + "Other": "1. Open the SharePoint admin center (admin.microsoft.com/sharepoint)\n2. Go to Policies > Access control > Apps that don't use modern authentication\n3. Select Block access and click Save", "Terraform": "" }, "Recommendation": { - "Text": "Block access for SharePoint applications that don't use modern authentication to ensure secure authentication mechanisms.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/sharepoint-online/set-spotenant?view=sharepoint-ps" + "Text": "Enforce **modern authentication** tenant-wide and disable legacy protocols. Require **MFA** and apply **conditional access** to all SharePoint apps. Migrate or block legacy clients, adhere to **least privilege** for app permissions, and monitor sign-ins to eradicate legacy auth usage.", + "Url": "https://hub.prowler.com/check/sharepoint_modern_authentication_required" } }, "Categories": [ - "e3" + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/m365/services/sharepoint/sharepoint_onedrive_sync_restricted_unmanaged_devices/sharepoint_onedrive_sync_restricted_unmanaged_devices.metadata.json b/prowler/providers/m365/services/sharepoint/sharepoint_onedrive_sync_restricted_unmanaged_devices/sharepoint_onedrive_sync_restricted_unmanaged_devices.metadata.json index 011f12ab15..f6a46d655a 100644 --- a/prowler/providers/m365/services/sharepoint/sharepoint_onedrive_sync_restricted_unmanaged_devices/sharepoint_onedrive_sync_restricted_unmanaged_devices.metadata.json +++ b/prowler/providers/m365/services/sharepoint/sharepoint_onedrive_sync_restricted_unmanaged_devices/sharepoint_onedrive_sync_restricted_unmanaged_devices.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "sharepoint_onedrive_sync_restricted_unmanaged_devices", - "CheckTitle": "Ensure OneDrive sync is restricted for unmanaged devices.", + "CheckTitle": "OneDrive sync from unmanaged devices is blocked", "CheckType": [], "ServiceName": "sharepoint", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Sharepoint Settings", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Microsoft OneDrive allows users to sign in their cloud tenant account and begin syncing select folders or the entire contents of OneDrive to a local computer. By default, this includes any computer with OneDrive already installed, whether it is Entra Joined, Entra Hybrid Joined or Active Directory Domain joined. The recommended state for this setting is Allow syncing only on computers joined to specific domains Enabled: Specify the AD domain GUID(s).", - "Risk": "Unmanaged devices can pose a security risk by allowing users to sync sensitive data to unauthorized devices, potentially leading to data leakage or unauthorized access.", - "RelatedUrl": "https://learn.microsoft.com/en-us/graph/api/resources/sharepoint?view=graph-rest-1.0", + "Description": "**Microsoft 365 SharePoint** tenant settings for **OneDrive sync** enforce that only **managed, domain-joined devices** can sync. The evaluation looks for a configured list of approved `domain GUIDs` that limits syncing to specific Active Directory domains.", + "Risk": "Without this restriction, users can sync SharePoint/OneDrive files to **unmanaged devices**, undermining:\n- **Confidentiality**: data copied to personal or lost endpoints, outside DLP.\n- **Integrity**: malicious edits synced back to sites.\n- **Availability**: mass deletion or ransomware can propagate via sync clients.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/sharepoint?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/sharepoint/allow-syncing-only-on-specific-domains" + ], "Remediation": { "Code": { - "CLI": "Set-SPOTenantSyncClientRestriction -Enable -DomainGuids '; ; ...'", + "CLI": "Set-SPOTenantSyncClientRestriction -Enable -DomainGuids ''", "NativeIaC": "", - "Other": "1. Navigate to SharePoint admin center https://admin.microsoft.com/sharepoint 2. Click Settings then select OneDrive - Sync. 3. Check the Allow syncing only on computers joined to specific domains. 4. Use the Get-ADDomain PowerShell command on the on-premises server to obtain the GUID for each on-premises domain. 5. Click Save.", + "Other": "1. Go to the SharePoint admin center: https://admin.microsoft.com/sharepoint\n2. Select Settings > Sync\n3. Check \"Allow syncing only on computers joined to specific domains\"\n4. Enter at least one AD domain GUID (separate multiple GUIDs with semicolons)\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict OneDrive sync to managed devices to prevent unauthorized access to sensitive data.", - "Url": "https://learn.microsoft.com/en-us/sharepoint/allow-syncing-only-on-specific-domains" + "Text": "Allow OneDrive sync only from **managed, domain-joined devices** by maintaining an approved `domain GUIDs` list. For Entra-joined devices, require **device compliance** via **Conditional Access**. Apply **least privilege**, use **DLP/sensitivity labels**, and periodically review exceptions.", + "Url": "https://hub.prowler.com/check/sharepoint_onedrive_sync_restricted_unmanaged_devices" } }, "Categories": [ + "trust-boundaries", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_email_sending_to_channel_disabled/teams_email_sending_to_channel_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_email_sending_to_channel_disabled/teams_email_sending_to_channel_disabled.metadata.json index 824af84af4..4a7c932cff 100644 --- a/prowler/providers/m365/services/teams/teams_email_sending_to_channel_disabled/teams_email_sending_to_channel_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_email_sending_to_channel_disabled/teams_email_sending_to_channel_disabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "teams_email_sending_to_channel_disabled", - "CheckTitle": "Ensure users are not be able to email the channel directly.", + "CheckTitle": "Email to Teams channel addresses is disabled", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Teams Settings", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure users can not send emails to channel email addresses.", - "Risk": "Allowing users to send emails to Teams channel email addresses introduces a security risk, as these addresses are outside the tenant’s domain and lack proper security controls. This creates a potential attack vector where threat actors could exploit the channel email to deliver malicious content or spam.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/get-csteamsclientconfiguration?view=teams-ps", + "Description": "**Microsoft 365 Teams** tenant configuration for **channel email addresses** determines if channels can receive messages via email. This evaluates the `allow_email_into_channel` setting.", + "Risk": "Allowing email into channels lets outsiders inject content, links, and attachments into Teams. Leaked addresses enable **phishing**, **malware delivery**, and spam, undermining **confidentiality** and **integrity**, and adding noise that affects **availability**; posts may bypass user-authenticated context.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/get-csteamsclientconfiguration?view=teams-ps" + ], "Remediation": { "Code": { "CLI": "Set-CsTeamsClientConfiguration -Identity Global -AllowEmailIntoChannel $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Teams select Teams settings. 3. Under email integration set Users can send emails to a channel email address to Off.", + "Other": "1. Sign in to the Microsoft Teams admin center: https://admin.teams.microsoft.com\n2. Go to Teams > Teams settings\n3. Under Email integration, set \"Users can send emails to a channel email address\" to Off\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable the ability for users to send emails to Teams channel email addresses to reduce the risk of external abuse and enhance control over organizational communications.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/get-csteamsclientconfiguration?view=teams-ps" + "Text": "Disable email into channels by default. If needed, limit senders to approved domains, apply anti-phishing/malware filtering, enforce DLP and retention on inbound mail, monitor postings, rotate channel addresses, and prefer authenticated connectors-applying **least privilege** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/teams_email_sending_to_channel_disabled" } }, "Categories": [ + "email-security", + "internet-exposed", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_external_domains_restricted/teams_external_domains_restricted.metadata.json b/prowler/providers/m365/services/teams/teams_external_domains_restricted/teams_external_domains_restricted.metadata.json index 2e38305a5e..2fb5c1e934 100644 --- a/prowler/providers/m365/services/teams/teams_external_domains_restricted/teams_external_domains_restricted.metadata.json +++ b/prowler/providers/m365/services/teams/teams_external_domains_restricted/teams_external_domains_restricted.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "teams_external_domains_restricted", - "CheckTitle": "Ensure external domains are restricted.", + "CheckTitle": "External domain access is disabled for Teams users", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Teams Settings", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure external domains are restricted from being used in Teams admin center.", - "Risk": "Allowing unrestricted communication with external domains in Microsoft Teams increases the risk of exposure to social engineering attacks, phishing, malware delivery (e.g., DarkGate), and exploitation tactics such as GIFShell or username enumeration.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "Description": "**Microsoft 365 Teams** tenant external access configuration is assessed. The expected posture is **federation with external domains** disabled, so users cannot chat, call, or meet with accounts in other domains.", + "Risk": "**Unrestricted external federation** enables delivery of phishing links and malware via chats/calls, user enumeration, and data leakage through messages or file shares. This directly threatens **confidentiality** and **integrity**, and can aid social engineering-driven lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "https://learn.microsoft.com/ar-sa/entra/architecture/5-secure-access-b2b" + ], "Remediation": { "Code": { "CLI": "Set-CsTenantFederationConfiguration -AllowFederatedUsers $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Click to expand Users select External access. 3. Under Teams and Skype for Business users in external organizations set Choose which external domains your users have access to to one of the following: Allow only specific external domains or Block all external domains. 4. Click Save.", + "Other": "1. Sign in to the Teams admin center: https://admin.teams.microsoft.com/\n2. Go to Org-wide settings (or Users) > External access\n3. Turn off \"Users can communicate with other Skype for Business and Teams users\"\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict external collaboration by configuring Teams to either Block all external domains or Allow only specific, trusted external domains. This ensures users can only interact with vetted organizations, significantly reducing the attack surface.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps" + "Text": "Adopt a **default-deny** stance: disable external access. *If collaboration is required*, allowlist only trusted domains and apply **least privilege** with cross-tenant policies. Prefer **B2B guest/shared channels**, require **MFA** and compliant devices, and review logs and domain lists regularly.", + "Url": "https://hub.prowler.com/check/teams_external_domains_restricted" } }, "Categories": [ + "trust-boundaries", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_external_file_sharing_restricted/teams_external_file_sharing_restricted.metadata.json b/prowler/providers/m365/services/teams/teams_external_file_sharing_restricted/teams_external_file_sharing_restricted.metadata.json index 5c0b3b463d..764e8656cd 100644 --- a/prowler/providers/m365/services/teams/teams_external_file_sharing_restricted/teams_external_file_sharing_restricted.metadata.json +++ b/prowler/providers/m365/services/teams/teams_external_file_sharing_restricted/teams_external_file_sharing_restricted.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "teams_external_file_sharing_restricted", - "CheckTitle": "Ensure external file sharing in Teams is enabled for only approved cloud storage services", + "CheckTitle": "External file sharing is restricted to only approved cloud storage services", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Teams Settings", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "", - "Risk": "Allowing unrestricted third-party cloud storage services in Teams increases the risk of data exfiltration, compliance violations, and unauthorized access to sensitive information. Users may store or share data through unapproved platforms with weaker security controls.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/get-csteamsclientconfiguration?view=teams-ps", + "Description": "**Microsoft 365 Teams** client settings restrict **external file sharing** via third-party storage providers to an approved allowlist. Configuration is considered in place when only sanctioned providers are enabled, or when all non-approved providers are disabled.", + "Risk": "Unrestricted third-party storage in Teams weakens **confidentiality** and **integrity**:\n- Data may bypass DLP, eDiscovery, and retention\n- Sensitive files can be shared to unmanaged tenants\n- Unvetted apps can deliver tampered content, enabling **data exfiltration** and **malware**", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/get-csteamsclientconfiguration?view=teams-ps" + ], "Remediation": { "Code": { - "CLI": "Set-CsTeamsClientConfiguration -AllowGoogleDrive $false -AllowShareFile $false -AllowBox $false -AllowDropBox $false -AllowEgnyte $false", + "CLI": "Set-CsTeamsClientConfiguration -AllowGoogleDrive $false -AllowShareFile $false -AllowBox $false -AllowDropbox $false -AllowEgnyte $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Teams select Teams settings. 3. Set any unauthorized providers to Off.", + "Other": "1. Go to https://admin.teams.microsoft.com and sign in\n2. Navigate to Teams > Teams settings\n3. Under Files > Third-party storage, turn Off any unapproved providers (Box, Dropbox, Google Drive, Egnyte, ShareFile)\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict external file sharing in Teams to only approved cloud storage providers, such as SharePoint Online and OneDrive. Configure Teams policies to block unauthorized services and enforce compliance with organizational data protection standards.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/get-csteamsclientconfiguration?view=teams-ps" + "Text": "Adopt a **deny-by-default allowlist** for Teams file sharing with third-party storage.\n- Enable only vetted providers aligned with governance\n- Prefer **SharePoint Online/OneDrive** for collaboration\n- Enforce **least privilege**, DLP, and eDiscovery on allowed paths\n- Block unsanctioned apps and limit external sharing to trusted domains", + "Url": "https://hub.prowler.com/check/teams_external_file_sharing_restricted" } }, "Categories": [ + "trust-boundaries", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_external_users_cannot_start_conversations/teams_external_users_cannot_start_conversations.metadata.json b/prowler/providers/m365/services/teams/teams_external_users_cannot_start_conversations/teams_external_users_cannot_start_conversations.metadata.json index 0601799453..6404fa6234 100644 --- a/prowler/providers/m365/services/teams/teams_external_users_cannot_start_conversations/teams_external_users_cannot_start_conversations.metadata.json +++ b/prowler/providers/m365/services/teams/teams_external_users_cannot_start_conversations/teams_external_users_cannot_start_conversations.metadata.json @@ -1,30 +1,37 @@ { "Provider": "m365", "CheckID": "teams_external_users_cannot_start_conversations", - "CheckTitle": "Ensure external users cannot start conversations.", + "CheckTitle": "Users cannot start conversations with unmanaged accounts", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Teams Settings", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure external users cannot initiate conversations.", - "Risk": "Allowing unmanaged external Teams users to initiate conversations increases the risk of phishing, malware distribution such as DarkGate, social engineering attacks like those by Midnight Blizzard, GIFShell exploitation, and username enumeration.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "Description": "**Microsoft 365 Teams** external access blocks conversation initiation from **unmanaged Teams accounts** when `AllowTeamsConsumerInbound=false`.", + "Risk": "Permitting unmanaged externals to start chats enables **phishing**, **malware delivery**, and **social engineering**, leading to credential theft and data exfiltration. It also allows **user enumeration** and presence probing, aiding **account takeover** and lateral movement, impacting confidentiality and integrity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat", + "https://learn.microsoft.com/en-us/entra/architecture/9-secure-access-teams-sharepoint", + "https://learn.microsoft.com/en-us/microsoftteams/communicate-with-users-from-other-organizations" + ], "Remediation": { "Code": { "CLI": "Set-CsTenantFederationConfiguration -AllowTeamsConsumerInbound $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Click to expand Users select External access. 3. Scroll to Teams accounts not managed by an organization. 4. Uncheck External users with Teams accounts not managed by an organization can contact users in my organization. 5. Click Save.", + "Other": "1. Sign in to the Teams admin center: https://admin.teams.microsoft.com/\n2. Go to Users > External access\n3. Under \"Teams accounts not managed by an organization\", clear the checkbox \"External users with Teams accounts not managed by an organization can contact users in my organization\"\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable the ability for external Teams users not managed by an organization to initiate conversations by unchecking the option that permits them to contact users in your organization. This provides an added layer of protection, especially if exceptions are made to allow limited communication with unmanaged users.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps" + "Text": "Disable inbound initiation from unmanaged accounts (`AllowTeamsConsumerInbound=false`). If external collaboration is required, prefer **allowlists** for trusted domains and use **guest access** with **least privilege**. Apply **defense in depth**: conditional access, link/file scanning, user education, and monitor for anomalous external chats.", + "Url": "https://hub.prowler.com/check/teams_external_users_cannot_start_conversations" } }, "Categories": [ + "trust-boundaries", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_anonymous_user_join_disabled/teams_meeting_anonymous_user_join_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_anonymous_user_join_disabled/teams_meeting_anonymous_user_join_disabled.metadata.json index 3aba1caac4..aff165662f 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_anonymous_user_join_disabled/teams_meeting_anonymous_user_join_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_anonymous_user_join_disabled/teams_meeting_anonymous_user_join_disabled.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "teams_meeting_anonymous_user_join_disabled", - "CheckTitle": "Ensure anonymous users are not able to join meetings.", + "CheckTitle": "Anonymous users cannot join Teams meetings", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Teams Global Meeting Policy", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure individuals who are not sent or forwarded a meeting invite will not be able to join the meeting automatically.", - "Risk": "Allowing anonymous users to join meetings can lead to unauthorized access, information leakage, and potential disruptions, especially in meetings involving sensitive data.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "Description": "**Microsoft 365 Teams** org-wide meeting policy is evaluated to ensure **anonymous meeting join** is disabled, preventing non-authenticated participants from joining.", + "Risk": "Anonymous meeting access allows unaccountable attendees to join, eavesdrop, capture shared content, and impersonate others.\n\nThis undermines **confidentiality** and **integrity**, and threatens **availability** via meeting hijacking, spam, and disruption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + ], "Remediation": { "Code": { "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToJoinMeeting $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting join & lobby set Anonymous users can join a meeting to Off.", + "Other": "1. Sign in to the Microsoft Teams admin center (https://admin.teams.microsoft.com)\n2. Go to Meetings > Meeting policies\n3. Select Global (Org-wide default)\n4. Set \"Anonymous users can join a meeting\" to Off\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable anonymous user access to Microsoft Teams meetings to ensure only invited participants can join. This adds a layer of vetting by requiring organizer approval for anyone not explicitly invited.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + "Text": "Disable **anonymous meeting join** tenant-wide and require authenticated users or managed guests.\n\nUse **lobby** admission for externals, limit presenter rights per **least privilege**, and enforce **conditional access** or registration to control who enters.", + "Url": "https://hub.prowler.com/check/teams_meeting_anonymous_user_join_disabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_anonymous_user_start_disabled/teams_meeting_anonymous_user_start_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_anonymous_user_start_disabled/teams_meeting_anonymous_user_start_disabled.metadata.json index 1dcc617f18..dc2bd0865e 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_anonymous_user_start_disabled/teams_meeting_anonymous_user_start_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_anonymous_user_start_disabled/teams_meeting_anonymous_user_start_disabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "teams_meeting_anonymous_user_start_disabled", - "CheckTitle": "Ensure anonymous users are not able to start meetings.", + "CheckTitle": "Anonymous users cannot start Teams meetings", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Teams Global Meeting Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure anonymous users and dial-in callers are not able to start meetings.", - "Risk": "Allowing anonymous users and dial-in callers to start meetings without an authenticated participant present can lead to meeting spamming, unauthorized activity, and potential misuse of organizational resources.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "Description": "**Microsoft 365 Teams meeting policies** disable `AllowAnonymousUsersToStartMeeting` so **anonymous users** and **dial-in callers** cannot start meetings and must wait in the lobby until an authenticated participant joins", + "Risk": "Without this control, outsiders can launch **hostless meetings**, enabling:\n- social engineering before staff join\n- malicious link sharing and meeting hijack\nIt also allows **PSTN toll abuse** by dial-in callers.\nImpacts: confidentiality, integrity, and availability/cost.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoftteams/settings-policies-reference", + "https://docs.tminus365.com/security/teams/anonymous-users-shall-not-be-enabled-to-start-meetings", + "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + ], "Remediation": { "Code": { "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AllowAnonymousUsersToStartMeeting $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting join & lobby set Anonymous users and dial-in callers can start a meeting to Off.", + "Other": "1. Sign in to the Microsoft Teams admin center (https://admin.teams.microsoft.com)\n2. Go to Meetings > Meeting policies\n3. Select Global (Org-wide default)\n4. Under Meeting join & lobby, set \"Anonymous users and dial-in callers can start a meeting\" to Off\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that anonymous users and dial-in callers are required to wait in the lobby until a verified user from the organization or a trusted external domain starts the meeting. This reduces the risk of abuse and maintains meeting integrity.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + "Text": "Keep `AllowAnonymousUsersToStartMeeting` disabled. Require an **authenticated organizer** to start meetings and enforce the **lobby** so anonymous and dial-in participants wait. Limit lobby bypass to internal or invited users, disable anonymous join if unnecessary, and apply least-privilege and zero-trust principles to meeting access.", + "Url": "https://hub.prowler.com/check/teams_meeting_anonymous_user_start_disabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_chat_anonymous_users_disabled/teams_meeting_chat_anonymous_users_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_chat_anonymous_users_disabled/teams_meeting_chat_anonymous_users_disabled.metadata.json index c17e49dab5..d740df14bf 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_chat_anonymous_users_disabled/teams_meeting_chat_anonymous_users_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_chat_anonymous_users_disabled/teams_meeting_chat_anonymous_users_disabled.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "teams_meeting_chat_anonymous_users_disabled", - "CheckTitle": "Ensure meeting chat does not allow anonymous users", + "CheckTitle": "Meetings global policy does not allow anonymous users in meeting chat", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Teams Global Meeting Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure meeting chat does not allow anonymous users.", - "Risk": "Allowing anonymous users to participate in meeting chat can expose sensitive information and increase the risk of inappropriate content being shared by unverified participants.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "Description": "**Microsoft 365 Teams meeting policies** restrict chat so **anonymous participants** cannot send or read messages.\n\nAccepted configurations include `EnabledExceptAnonymous` or `EnabledInMeetingOnlyForAllExceptAnonymous`.", + "Risk": "**Anonymous chat** enables unverified users to leak sensitive content, post **phishing/malware links**, and impersonate others.\n\nThis undermines **confidentiality** and accountability, and can disrupt meetings through spam, affecting **availability** and auditability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + ], "Remediation": { "Code": { - "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -MeetingChatEnabledType 'EnabledExceptAnonymous'", + "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -MeetingChatEnabledType EnabledExceptAnonymous", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting engagement verify that Meeting chat is set to On for everyone but anonymous users.", + "Other": "1. Sign in to the Microsoft Teams admin center: https://admin.teams.microsoft.com\n2. Go to Meetings > Meeting policies\n3. Open Global (Org-wide default)\n4. Under Meeting engagement, set Meeting chat to \"On for everyone but anonymous users\"\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict chat access during meetings to only authenticated and authorized users. Disable chat capabilities for anonymous users to maintain confidentiality and prevent misuse.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + "Text": "Enforce chat for **authenticated users only** following **least privilege**.\n- Block chat for anonymous users\n- Use guest access with identity verification and lobby controls\n- Apply DLP and link/file protection to chat\n- Monitor audit logs and set retention to ensure traceability", + "Url": "https://hub.prowler.com/check/teams_meeting_chat_anonymous_users_disabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_dial_in_lobby_bypass_disabled/teams_meeting_dial_in_lobby_bypass_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_dial_in_lobby_bypass_disabled/teams_meeting_dial_in_lobby_bypass_disabled.metadata.json index 871777fa52..10fc89c76b 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_dial_in_lobby_bypass_disabled/teams_meeting_dial_in_lobby_bypass_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_dial_in_lobby_bypass_disabled/teams_meeting_dial_in_lobby_bypass_disabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "teams_meeting_dial_in_lobby_bypass_disabled", - "CheckTitle": "Ensure that dial-in users cannot bypass the lobby in Teams meetings", + "CheckTitle": "Meetings global policy does not allow dial-in users to bypass the lobby", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Teams Global Meeting Policy", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure that dial-in users cannot bypass the lobby in Teams meetings", - "Risk": "Allowing dial-in users to bypass the lobby may result in unauthorized or unauthenticated individuals joining sensitive meetings without prior validation, increasing the risk of information leakage or meeting disruptions.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "Description": "**Microsoft 365 Teams meeting policies** prevent **PSTN dial-in callers** from bypassing the lobby (`AllowPSTNUsersToBypassLobby=false`), requiring admission by organizers or presenters.", + "Risk": "Direct admission of dial-in callers enables unauthenticated access, caller-ID spoofing, and meeting hijacking. Sensitive content can be overheard or recorded (**confidentiality**), discussions manipulated (**integrity**), and sessions disrupted (**availability**).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoftteams/who-can-bypass-meeting-lobby", + "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + ], "Remediation": { "Code": { "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AllowPSTNUsersToBypassLobby $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting join & lobby set People dialing in can bypass the lobby to Off.", + "Other": "1. Sign in to the Microsoft Teams admin center: https://admin.teams.microsoft.com\n2. Go to Meetings > Meeting policies\n3. Select Global (Org-wide default)\n4. In Meeting join & lobby, set \"People dialing in can bypass the lobby\" to Off\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Require all users dialing in by phone to wait in the lobby until admitted by the meeting organizer, co-organizer, or presenter. This ensures proper vetting before granting access to potentially sensitive discussions.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + "Text": "Enforce the lobby for all **PSTN dial-in callers**. Restrict admission to organizers or presenters, and allow only authenticated or explicitly invited users to bypass. Standardize via org-wide meeting policies or templates to uphold **least privilege** and **defense in depth**.", + "Url": "https://hub.prowler.com/check/teams_meeting_dial_in_lobby_bypass_disabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_external_chat_disabled/teams_meeting_external_chat_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_external_chat_disabled/teams_meeting_external_chat_disabled.metadata.json index c33e967f64..1bc3400ba1 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_external_chat_disabled/teams_meeting_external_chat_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_external_chat_disabled/teams_meeting_external_chat_disabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "teams_meeting_external_chat_disabled", - "CheckTitle": "Ensure external meeting chat is off", + "CheckTitle": "Meeting chat for untrusted organizations is disabled", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Teams Global Meeting Policy", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure users can't read or write messages in external meeting chats with untrusted organizations.", - "Risk": "Allowing chat in external meetings increases the risk of exploits like GIFShell or DarkGate malware being delivered to users through untrusted organizations.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "Description": "**Microsoft 365 Teams meeting policy** setting `AllowExternalNonTrustedMeetingChat` governs whether users can read or send chat messages in meetings hosted by **untrusted organizations**.\n\nThis assesses the org-wide default policy to confirm external meeting chat with non-trusted tenants is blocked.", + "Risk": "Permitting chat in external meetings with **untrusted tenants** risks:\n- Confidential data exposure via messages/files\n- **Malware delivery** through links or media (e.g., GIF-based techniques)\n- **Social engineering** enabling account compromise and **lateral movement**, degrading confidentiality and integrity", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/microsoftteams/set-csteamsmeetingpolicy?view=teams-ps", + "https://office365itpros.com/2023/10/23/block-meeting-chat-untrusted/", + "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + ], "Remediation": { "Code": { - "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalNonTrustedMeetingChat $false", + "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalNonTrustedMeetingChat $False", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting engagement set External meeting chat to Off.", + "Other": "1. Sign in to the Teams admin center: https://admin.teams.microsoft.com\n2. Go to Meetings > Meeting policies\n3. Select Global (Org-wide default)\n4. Under Meeting engagement, set External meeting chat (untrusted organizations) to Off\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable external meeting chat to prevent potential security risks from untrusted organizations. This helps protect against exploits like GIFShell or DarkGate malware.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + "Text": "Block chat for meetings hosted by **non-trusted organizations** and restrict collaboration to a vetted allowlist.\n\nAdopt **defense in depth**: limit content sharing, enable link/file scanning, enforce **DLP**, and user training. Review external trust relationships regularly per **least privilege**.", + "Url": "https://hub.prowler.com/check/teams_meeting_external_chat_disabled" } }, "Categories": [ + "trust-boundaries", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_external_control_disabled/teams_meeting_external_control_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_external_control_disabled/teams_meeting_external_control_disabled.metadata.json index d9aeff1830..785718c5ba 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_external_control_disabled/teams_meeting_external_control_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_external_control_disabled/teams_meeting_external_control_disabled.metadata.json @@ -1,30 +1,36 @@ { "Provider": "m365", "CheckID": "teams_meeting_external_control_disabled", - "CheckTitle": "Ensure external participants can't give or request control", + "CheckTitle": "Meetings global policy prevents external participants from giving or requesting control", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "Teams Global Meeting Policy", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure external participants can't give or request control in Teams meetings.", - "Risk": "Allowing external participants to give or request control during meetings could lead to unauthorized content sharing or malicious actions by external users.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "Description": "**Microsoft 365 Teams meeting policies** govern whether **external participants** can give, be given, or request control during screen sharing via `allowExternalParticipantGiveRequestControl`.\n\nEvaluation targets the org-wide default policy to confirm external control actions are blocked.", + "Risk": "External control during sharing enables remote input on the presenter's device, impacting:\n- Confidentiality: viewing/copying sensitive data\n- Integrity: unauthorized changes, keystroke injection, malware launch\n- Availability: session disruption or app termination", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/microsoftteams/set-csteamsmeetingpolicy?view=teams-ps", + "https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control", + "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + ], "Remediation": { "Code": { "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AllowExternalParticipantGiveRequestControl $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under content sharing set External participants can give or request control to Off.", + "Other": "1. Open Microsoft Teams admin center: https://admin.teams.microsoft.com\n2. Go to Meetings > Meeting policies\n3. Select Global (Org-wide default)\n4. In Content sharing, set \"External participants can give or request control\" to Off\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable the ability for external participants to give or request control during Teams meetings to prevent unauthorized content sharing and maintain meeting security.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + "Text": "Apply **least privilege**: disable external give/request control by setting `allowExternalParticipantGiveRequestControl=false` in the org-wide policy.\n\nIf business-justified, restrict presenters to trusted users, limit sharing to `SingleApplication`, use lobby/presenter roles, and monitor for misuse.", + "Url": "https://hub.prowler.com/check/teams_meeting_external_control_disabled" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_external_lobby_bypass_disabled/teams_meeting_external_lobby_bypass_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_external_lobby_bypass_disabled/teams_meeting_external_lobby_bypass_disabled.metadata.json index ef7ca6cd03..4b33b99bce 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_external_lobby_bypass_disabled/teams_meeting_external_lobby_bypass_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_external_lobby_bypass_disabled/teams_meeting_external_lobby_bypass_disabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "m365", "CheckID": "teams_meeting_external_lobby_bypass_disabled", - "CheckTitle": "Ensure only people in the organization can bypass the lobby.", + "CheckTitle": "Meetings global policy allows only people in the organization to bypass the lobby", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "Teams Global Meeting Policy", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure only people in the organization can bypass the lobby.", - "Risk": "Allowing external users or unauthenticated participants to bypass the lobby increases the risk of unauthorized access to sensitive meetings and potential disruptions. It may also lead to unscheduled meetings being initiated by external parties.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "Description": "Teams Meetings global policy restricts **lobby bypass** so only `EveryoneInCompanyExcludingGuests`, `OrganizerOnly`, or `InvitedUsers` are auto-admitted; all others wait in the lobby.", + "Risk": "Auto-admitting external or anonymous users undermines **confidentiality** via covert listening and access to chat/files, and **integrity** through meeting hijacks, malicious screen sharing, and phishing. It also impacts **availability** by enabling disruptions and unsanctioned sessions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + ], "Remediation": { "Code": { - "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers 'EveryoneInCompanyExcludingGuests' ", + "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AutoAdmittedUsers 'EveryoneInCompanyExcludingGuests'", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under meeting join & lobby set Who can bypass the lobby to People in my org.", + "Other": "1. Sign in to the Microsoft Teams admin center (https://admin.teams.microsoft.com)\n2. Go to Meetings > Meeting policies\n3. Select Global (Org-wide default)\n4. Under Meeting join & lobby, set Who can bypass the lobby to People in my org\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Ensure that only people within the organization can bypass the lobby, requiring external users and dial-in participants to wait for approval from an organizer, co-organizer, or presenter. This helps secure sensitive meetings and prevents unauthorized access.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + "Text": "Apply **least privilege** to lobby admission: set auto-admit to internal-only or to organizer/invitees, and require approval for guests, federated, anonymous, and PSTN. Enforce **authentication** and **conditional access**, and default to limited presenters for **defense in depth**.", + "Url": "https://hub.prowler.com/check/teams_meeting_external_lobby_bypass_disabled" } }, "Categories": [ + "identity-access", + "trust-boundaries", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_presenters_restricted/teams_meeting_presenters_restricted.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_presenters_restricted/teams_meeting_presenters_restricted.metadata.json index 5e2ea4231c..d45e27c4df 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_presenters_restricted/teams_meeting_presenters_restricted.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_presenters_restricted/teams_meeting_presenters_restricted.metadata.json @@ -1,30 +1,34 @@ { "Provider": "m365", "CheckID": "teams_meeting_presenters_restricted", - "CheckTitle": "Ensure only organizers and co-organizers can present", + "CheckTitle": "Meetings org-wide default policy allows only organizers and co-organizers to present", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Teams Global Meeting Policy", + "Severity": "medium", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure only organizers and co-organizers can present in a Teams meeting. The recommended state is 'Only organizers and co-organizers'.", - "Risk": "Allowing everyone to present increases the risk that a malicious user can inadvertently show inappropriate content.", - "RelatedUrl": "https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control", + "Description": "**Teams meeting policy** sets the default `Who can present` to **only organizers and co-organizers** in the org-wide policy.\n\nThis evaluates whether attendees are limited to the attendee role by default rather than joining as presenters.", + "Risk": "Allowing everyone to present enables unsolicited screen sharing and content uploads, causing data exposure (confidentiality), misleading or altered information during sessions (integrity), and meeting takeovers or disruptions (availability). External participants can exploit this to distribute phishing links or malware.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control" + ], "Remediation": { "Code": { "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -DesignatedPresenterRoleMode \"OrganizerOnlyUserOverride\"", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under content sharing set Who can present to Only organizers and co-organizers.", + "Other": "1. Sign in to the Teams admin center: https://admin.teams.microsoft.com\n2. Go to Meetings > Meeting policies\n3. Select Global (Org-wide default)\n4. Under Content sharing, set Who can present to \"Only organizers and co-organizers\"\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Restrict presentation capabilities to only organizers and co-organizers to reduce the risk of inappropriate content being shown.", - "Url": "https://learn.microsoft.com/en-us/microsoftteams/meeting-who-present-request-control" + "Text": "Set `Who can present` to **Only organizers and co-organizers** by default.\n\nApply **least privilege**:\n- Grant presenter rights only to designated users per meeting\n- Keep others as attendees, especially externals\n- Use lobby/guest controls to limit elevation\n\nAdopt **defense in depth** with monitoring and clear host procedures.", + "Url": "https://hub.prowler.com/check/teams_meeting_presenters_restricted" } }, "Categories": [ + "identity-access", "e3" ], "DependsOn": [], diff --git a/prowler/providers/m365/services/teams/teams_meeting_recording_disabled/teams_meeting_recording_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_meeting_recording_disabled/teams_meeting_recording_disabled.metadata.json index 5d1c3c03cc..b74e03fd31 100644 --- a/prowler/providers/m365/services/teams/teams_meeting_recording_disabled/teams_meeting_recording_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_meeting_recording_disabled/teams_meeting_recording_disabled.metadata.json @@ -1,27 +1,30 @@ { "Provider": "m365", "CheckID": "teams_meeting_recording_disabled", - "CheckTitle": "Ensure meeting recording is disabled by default", + "CheckTitle": "Meetings global (Org-wide default) policy has meeting recording disabled by default", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "Teams Global Meeting Policy", + "Severity": "medium", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensures that only authorized users, such as organizers, co-organizers, and leads, can initiate a recording.", - "Risk": "Allowing meeting recordings by default increases the risk of unauthorized individuals capturing and potentially sharing sensitive meeting content.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps", + "Description": "**Microsoft 365 Teams** Global meeting policy has **cloud meeting recording** disabled by default (`AllowCloudRecording=false`).", + "Risk": "Recording allowed by default enables uncontrolled capture of meetings, threatening **confidentiality**. Files and transcripts persist in collaboration stores and can be broadly shared, leading to insider exfiltration, accidental leakage, and long-lived exposure of sensitive discussions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + ], "Remediation": { "Code": { "CLI": "Set-CsTeamsMeetingPolicy -Identity Global -AllowCloudRecording $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com. 2. Click to expand Meetings select Meeting policies. 3. Click Global (Org-wide default). 4. Under Recording & transcription set Meeting recording to Off.", + "Other": "1. Sign in to Microsoft Teams admin center: https://admin.teams.microsoft.com\n2. Go to Meetings > Meeting policies\n3. Select Global (Org-wide default)\n4. Under Recording & transcription, set Cloud recording to Off\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable meeting recording in the Global meeting policy to ensure only authorized users can initiate recordings. Create separate policies for users or groups who need recording capabilities.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-csteamsmeetingpolicy?view=teams-ps" + "Text": "Adopt a stance of **no default recording** and grant recording only to specific roles or groups per **least privilege**. Require explicit consent, restrict sharing to need-to-know, and apply retention and access controls. Periodically review policies as part of **defense in depth** to minimize data exposure.", + "Url": "https://hub.prowler.com/check/teams_meeting_recording_disabled" } }, "Categories": [ diff --git a/prowler/providers/m365/services/teams/teams_security_reporting_enabled/teams_security_reporting_enabled.metadata.json b/prowler/providers/m365/services/teams/teams_security_reporting_enabled/teams_security_reporting_enabled.metadata.json index 4f430111d5..1d08b937ea 100644 --- a/prowler/providers/m365/services/teams/teams_security_reporting_enabled/teams_security_reporting_enabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_security_reporting_enabled/teams_security_reporting_enabled.metadata.json @@ -1,27 +1,32 @@ { "Provider": "m365", "CheckID": "teams_security_reporting_enabled", - "CheckTitle": "Ensure users can report security concerns in Teams", + "CheckTitle": "Messaging policy has security reporting enabled", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "Teams Global Messaging Policy", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure Teams user reporting settings allow a user to report a message as malicious for further analysis", - "Risk": "Without proper security reporting enabled, users cannot effectively report suspicious or malicious messages, potentially allowing security threats to go unnoticed.", - "RelatedUrl": "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide", + "Description": "**Teams messaging policies** enable **end-user security reporting** via `AllowSecurityEndUserReporting`, letting users report messages from chats, channels, and meetings for security review.", + "Risk": "**Disabled reporting** hides **phishing, malicious links, and social engineering** in Teams.\n\nThis delays detection and response, enabling **lateral movement** and **data exfiltration**, and degrading **confidentiality** and **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/microsoftteams/set-csteamsmessagingpolicy?view=teams-ps", + "https://blog.hametbenoit.info/2023/03/30/teams-you-can-now-enable-quarantine-for-teams-preview/", + "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide" + ], "Remediation": { "Code": { "CLI": "Set-CsTeamsMessagingPolicy -Identity Global -AllowSecurityEndUserReporting $true", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center (https://admin.teams.microsoft.com). 2. Click to expand Messaging and select Messaging policies. 3. Click Global (Org-wide default). 4. Ensure Report a security concern is On.", + "Other": "1. Sign in to the Microsoft Teams admin center: https://admin.teams.microsoft.com\n2. Go to Messaging policies\n3. Open Global (Org-wide default)\n4. Turn on \"Report a security concern\"\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable security reporting in Teams messaging policy.", - "Url": "https://learn.microsoft.com/en-us/defender-office-365/submissions-teams?view=o365-worldwide" + "Text": "Enable `AllowSecurityEndUserReporting` in relevant messaging policies and route submissions to security operations for timely triage.\n\nReinforce **defense in depth** with link/file protection and monitoring, train users to report suspicious content, and apply **least privilege** to administrative access.", + "Url": "https://hub.prowler.com/check/teams_security_reporting_enabled" } }, "Categories": [ diff --git a/prowler/providers/m365/services/teams/teams_unmanaged_communication_disabled/teams_unmanaged_communication_disabled.metadata.json b/prowler/providers/m365/services/teams/teams_unmanaged_communication_disabled/teams_unmanaged_communication_disabled.metadata.json index 1fbe76de62..3966b17c39 100644 --- a/prowler/providers/m365/services/teams/teams_unmanaged_communication_disabled/teams_unmanaged_communication_disabled.metadata.json +++ b/prowler/providers/m365/services/teams/teams_unmanaged_communication_disabled/teams_unmanaged_communication_disabled.metadata.json @@ -1,31 +1,36 @@ { "Provider": "m365", "CheckID": "teams_unmanaged_communication_disabled", - "CheckTitle": "Ensure unmanaged communication is disabled.", + "CheckTitle": "Users cannot communicate with unmanaged users", "CheckType": [], "ServiceName": "teams", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "critical", - "ResourceType": "Teams Settings", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "collaboration", - "Description": "Ensure unmanaged communication is disabled in Teams admin center.", - "Risk": "Allowing communication with unmanaged Microsoft Teams users increases the risk of targeted attacks such as phishing, malware distribution (e.g., DarkGate), and exploitation techniques like GIFShell and username enumeration. Unmanaged accounts are easier for threat actors to create and use as attack vectors.", - "RelatedUrl": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "Description": "**Microsoft 365 Teams** external access configuration for **unmanaged Teams accounts** is reviewed, expecting the \"Teams accounts not managed by an organization\" option to be `Off`, preventing chats with personal Microsoft accounts.", + "Risk": "Allowing unmanaged accounts enables unsolicited contact that undermines **confidentiality** and **integrity**: attackers can enumerate users, deliver phishing or malware links, and run social-engineering leading to data exfiltration and unauthorized changes. It also fuels spam and alert fatigue.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps", + "https://learn.microsoft.com/en-us/microsoftteams/trusted-organizations-external-meetings-chat" + ], "Remediation": { "Code": { "CLI": "Set-CsTenantFederationConfiguration -AllowTeamsConsumer $false", "NativeIaC": "", - "Other": "1. Navigate to Microsoft Teams admin center https://admin.teams.microsoft.com/. 2. Click to expand Users select External access. 3. Scroll to Teams accounts not managed by an organization. 4. Set People in my organization can communicate with Teams users whose accounts aren't managed by an organization to Off. 5. Click Save.", + "Other": "1. Sign in to the Microsoft Teams admin center\n2. Go to Users > External access\n3. Under \"Teams accounts not managed by an organization\", turn OFF \"People in my organization can communicate with Teams users whose accounts aren't managed by an organization\"\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Disable communication with Teams users whose accounts aren't managed by an organization by setting 'People in my organization can communicate with Teams users whose accounts aren't managed by an organization' to Off. This helps prevent unauthorized or risky external interactions.", - "Url": "https://learn.microsoft.com/en-us/powershell/module/teams/set-cstenantfederationconfiguration?view=teams-ps" + "Text": "Disable communication with **unmanaged Teams accounts** to enforce **least privilege** and reduce attack surface.\n\nIf collaboration is needed, allow only outbound initiation, prefer **guest access** or trusted domains, apply **defense in depth** with DLP/link protection, and monitor external interactions.", + "Url": "https://hub.prowler.com/check/teams_unmanaged_communication_disabled" } }, "Categories": [ - "e3" + "e3", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/mongodbatlas/lib/arguments/arguments.py b/prowler/providers/mongodbatlas/lib/arguments/arguments.py index 13bdb2a975..64aff68343 100644 --- a/prowler/providers/mongodbatlas/lib/arguments/arguments.py +++ b/prowler/providers/mongodbatlas/lib/arguments/arguments.py @@ -1,3 +1,6 @@ +SENSITIVE_ARGUMENTS = frozenset({"--atlas-private-key"}) + + def init_parser(self): """Initialize the MongoDB Atlas Provider CLI parser""" mongodbatlas_parser = self.subparsers.add_parser( diff --git a/prowler/providers/mongodbatlas/services/clusters/clusters_authentication_enabled/clusters_authentication_enabled.metadata.json b/prowler/providers/mongodbatlas/services/clusters/clusters_authentication_enabled/clusters_authentication_enabled.metadata.json index 5c1bf7d05b..0f07feba93 100644 --- a/prowler/providers/mongodbatlas/services/clusters/clusters_authentication_enabled/clusters_authentication_enabled.metadata.json +++ b/prowler/providers/mongodbatlas/services/clusters/clusters_authentication_enabled/clusters_authentication_enabled.metadata.json @@ -1,30 +1,38 @@ { "Provider": "mongodbatlas", "CheckID": "clusters_authentication_enabled", - "CheckTitle": "Ensure MongoDB Atlas clusters have authentication enabled", + "CheckTitle": "Cluster has authentication enabled", "CheckType": [], "ServiceName": "clusters", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "MongoDBAtlasCluster", + "Severity": "critical", + "ResourceType": "NotDefined", "ResourceGroup": "database", - "Description": "Ensure MongoDB Atlas clusters have authentication enabled to prevent unauthorized access", - "Risk": "Without authentication enabled, MongoDB Atlas clusters may be vulnerable to unauthorized access, potentially exposing sensitive data or allowing malicious actions", + "Description": "**MongoDB Atlas clusters** enforce **database authentication** for client connections (`authEnabled`).\n\nIdentifies clusters where authentication is not required for access.", + "Risk": "Without authentication, anyone with network reach can access the database, compromising **confidentiality** and **integrity**. Attackers can exfiltrate data, modify or delete records, and create backdoor users, leading to outages and data loss that impact **availability**.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.mongodb.com/docs/atlas/security/quick-start/", + "https://www.mongodb.com/docs/atlas/security-ldaps-okta/", + "https://www.mongodb.com/docs/atlas/security/config-db-auth/", + "https://www.mongodb.com/docs/atlas/security-ldaps-onelogin/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "atlas dbusers create --username --password --role readWriteAnyDatabase@admin --projectId ", "NativeIaC": "", - "Other": "https://www.mongodb.com/docs/atlas/security/config-db-auth/", - "Terraform": "" + "Other": "1. In the Atlas UI, open your project and go to Security > Database Access\n2. Click Add New Database User\n3. Select Username/Password, enter a username and password\n4. Assign a role (e.g., readWriteAnyDatabase on admin)\n5. Click Add User to save\n\nThis creates a database user, enabling authentication for the cluster", + "Terraform": "```hcl\n# Create a MongoDB Atlas database user to enable authentication\nresource \"mongodbatlas_database_user\" \"example_resource_name\" {\n project_id = \"\"\n username = \"\"\n password = \"\"\n auth_database_name = \"admin\" # Critical: SCRAM user on admin enables DB auth\n\n roles { # Minimal role assignment required to create the user\n role_name = \"readWriteAnyDatabase\"\n database_name = \"admin\"\n }\n}\n```" }, "Recommendation": { - "Text": "Enable authentication for MongoDB Atlas clusters by setting authEnabled to true in the cluster configuration.", - "Url": "https://www.mongodb.com/docs/atlas/security/config-db-auth/" + "Text": "Enable and enforce **database authentication** on all clusters.\n- Prefer strong methods (X.509, OIDC, AWS IAM) over static passwords\n- Grant **least privilege** roles and avoid shared accounts\n- Rotate credentials/keys\n- Combine with IP access lists or private endpoints for **defense in depth**", + "Url": "https://hub.prowler.com/check/clusters_authentication_enabled" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/mongodbatlas/services/clusters/clusters_backup_enabled/clusters_backup_enabled.metadata.json b/prowler/providers/mongodbatlas/services/clusters/clusters_backup_enabled/clusters_backup_enabled.metadata.json index 40e7e74fe4..ef5879a694 100644 --- a/prowler/providers/mongodbatlas/services/clusters/clusters_backup_enabled/clusters_backup_enabled.metadata.json +++ b/prowler/providers/mongodbatlas/services/clusters/clusters_backup_enabled/clusters_backup_enabled.metadata.json @@ -1,30 +1,38 @@ { "Provider": "mongodbatlas", "CheckID": "clusters_backup_enabled", - "CheckTitle": "Ensure MongoDB Atlas clusters have backup enabled", + "CheckTitle": "Cluster has backup enabled", "CheckType": [], "ServiceName": "clusters", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "MongoDBAtlasCluster", + "ResourceType": "NotDefined", "ResourceGroup": "database", - "Description": "Ensure MongoDB Atlas clusters have backup enabled to protect against data loss", - "Risk": "Without backup enabled, MongoDB Atlas clusters are vulnerable to data loss in case of failures, corruption, or accidental deletion", + "Description": "**MongoDB Atlas clusters** have **automated backups** enabled (`cloudBackup`/`backup_enabled`) to generate snapshots and support restore operations", + "Risk": "Without **backups**, deleted or corrupted records are irrecoverable, undermining **integrity**. Failures or bad deployments can cause prolonged **unavailability** with no restore path. Lacking **point-in-time recovery** prevents precise rollbacks, amplifying the blast radius of mistakes or ransomware.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://mongodb.prakticum-team.ru/docs/atlas/recover-pit-continuous-cloud-backup/", + "https://mongodb.prakticum-team.ru/docs/atlas/backup/cloud-backup/dedicated-cluster-backup/", + "https://www.mongodb.com/docs/atlas/cli/v1.38/command/atlas-backups-compliancepolicy-pointintimerestores-enable/", + "https://www.mongodb.com/docs/atlas/backup-restore-cluster/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "curl -sS -u \":\" --digest -H \"Content-Type: application/json\" -X PATCH \"https://cloud.mongodb.com/api/atlas/v1.0/groups//clusters/\" --data '{\"cloudBackup\":true}'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Sign in to MongoDB Atlas and open your project\n2. Go to Database > Clusters\n3. For the target cluster, click the three dots (...) > Edit Configuration\n4. Under Additional Settings, toggle Cloud Backup to On\n5. Click Save Changes", + "Terraform": "```hcl\nresource \"mongodbatlas_cluster\" \"\" {\n project_id = \"\"\n name = \"\"\n\n cloud_backup = true # Critical: enables Cloud Backups so the check passes\n}\n```" }, "Recommendation": { - "Text": "Enable backup for MongoDB Atlas clusters by setting backupEnabled to true in the cluster configuration.", - "Url": "https://www.mongodb.com/docs/atlas/backup-restore-cluster/" + "Text": "Enable **Cloud Backups** on all production clusters and define backup schedules and retention that meet business **RPO/RTO**. For critical data, enable **point-in-time recovery**. Apply **least privilege** and **separation of duties** to backup access, protect against deletion with policy, monitor backup health, and perform regular test restores.", + "Url": "https://hub.prowler.com/check/clusters_backup_enabled" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/mongodbatlas/services/clusters/clusters_encryption_at_rest_enabled/clusters_encryption_at_rest_enabled.metadata.json b/prowler/providers/mongodbatlas/services/clusters/clusters_encryption_at_rest_enabled/clusters_encryption_at_rest_enabled.metadata.json index 624b7ebc85..0b07fd12ae 100644 --- a/prowler/providers/mongodbatlas/services/clusters/clusters_encryption_at_rest_enabled/clusters_encryption_at_rest_enabled.metadata.json +++ b/prowler/providers/mongodbatlas/services/clusters/clusters_encryption_at_rest_enabled/clusters_encryption_at_rest_enabled.metadata.json @@ -1,30 +1,35 @@ { "Provider": "mongodbatlas", "CheckID": "clusters_encryption_at_rest_enabled", - "CheckTitle": "Ensure MongoDB Atlas clusters have encryption at rest enabled", + "CheckTitle": "Cluster has encryption at rest enabled with a supported provider or EBS volume encryption", "CheckType": [], "ServiceName": "clusters", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "high", - "ResourceType": "MongoDBAtlasCluster", + "Severity": "critical", + "ResourceType": "NotDefined", "ResourceGroup": "database", - "Description": "Ensure that MongoDB Atlas clusters have encryption at rest enabled to protect data stored on disk. Encryption at rest provides an additional layer of security by encrypting data before it's written to storage, protecting against unauthorized access to the underlying storage media.", - "Risk": "If encryption at rest is not enabled on MongoDB Atlas clusters, sensitive data stored in the database is vulnerable to unauthorized access if the underlying storage is compromised. This could lead to data breaches, compliance violations, and exposure of sensitive information.", - "RelatedUrl": "https://www.mongodb.com/docs/atlas/security-kms-encryption/", + "Description": "**MongoDB Atlas clusters** are evaluated for **encryption at rest**. The check looks for a supported encryption provider configured (not `NONE`) or storage-level encryption enabled (e.g., `encryptEBSVolume=true`). Unsupported providers or explicit disablement are highlighted.", + "Risk": "Absent or misconfigured at-rest encryption allows disks and snapshots to be read if storage, backups, or images are accessed by attackers or insiders. This exposes sensitive records, erodes **confidentiality**, and enables quiet **data exfiltration** after host or control-plane compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.mongodb.com/docs/atlas/security-kms-encryption/" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In Atlas, go to Security > Advanced\n2. Toggle \"Encryption at Rest using your Key Management\" to On and enter your provider details (AWS KMS/Azure Key Vault/GCP KMS)\n3. Save\n4. Go to Database > Clusters, open the menu for and click Edit Configuration\n5. Expand Additional Settings and set \"Manage your own encryption keys\" to Yes\n6. Review Changes > Apply Changes", + "Terraform": "```hcl\n# Enable project-level KMS\nresource \"mongodbatlas_encryption_at_rest\" \"\" {\n project_id = \"\"\n aws_kms_config {\n enabled = true # critical: turns on KMS for the project so clusters can use it\n customer_master_key_id = \"\"\n region = \"\"\n role_id = \"\"\n }\n}\n\n# Set the cluster to use a supported encryption provider\nresource \"mongodbatlas_advanced_cluster\" \"\" {\n project_id = \"\"\n name = \"\"\n cluster_type = \"REPLICASET\"\n encryption_at_rest_provider = \"AWS\" # critical: enables encryption at rest with a supported provider\n\n replication_specs {\n region_configs {\n provider_name = \"AWS\"\n region_name = \"\"\n electable_specs {\n instance_size = \"M10\"\n node_count = 3\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Enable encryption at rest for your MongoDB Atlas clusters. This can be configured when creating a new cluster or by modifying an existing cluster's settings. Choose an appropriate encryption provider (AWS KMS, Azure Key Vault, or Google Cloud KMS) based on your cloud provider and security requirements.", - "Url": "https://www.mongodb.com/docs/atlas/security-kms-encryption/" + "Text": "Enable **encryption at rest** with a supported provider and prefer **customer-managed keys** to control lifecycle and access. Apply least-privilege to key usage, restrict KMS connectivity, and monitor key health and rotation. If using AWS, also ensure volumes are encrypted. This enforces defense-in-depth and data confidentiality.", + "Url": "https://hub.prowler.com/check/clusters_encryption_at_rest_enabled" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/mongodbatlas/services/clusters/clusters_tls_enabled/clusters_tls_enabled.metadata.json b/prowler/providers/mongodbatlas/services/clusters/clusters_tls_enabled/clusters_tls_enabled.metadata.json index 208b9daef7..08191b44d8 100644 --- a/prowler/providers/mongodbatlas/services/clusters/clusters_tls_enabled/clusters_tls_enabled.metadata.json +++ b/prowler/providers/mongodbatlas/services/clusters/clusters_tls_enabled/clusters_tls_enabled.metadata.json @@ -1,30 +1,37 @@ { "Provider": "mongodbatlas", "CheckID": "clusters_tls_enabled", - "CheckTitle": "Ensure MongoDB Atlas clusters have TLS authentication required", + "CheckTitle": "Cluster has TLS authentication enabled", "CheckType": [], "ServiceName": "clusters", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "MongoDBAtlasCluster", + "ResourceType": "NotDefined", "ResourceGroup": "database", - "Description": "Ensure MongoDB Atlas clusters have TLS authentication required to secure data in transit", - "Risk": "Without TLS enabled, MongoDB Atlas clusters are vulnerable to man-in-the-middle attacks and data interception during transmission", + "Description": "**MongoDB Atlas clusters** require **TLS/SSL** for client connections to encrypt data in transit (`sslEnabled=true`).", + "Risk": "Without enforced **TLS**, traffic can be intercepted or altered, degrading **confidentiality** and **integrity**.\n\nAttackers can run **man-in-the-middle** attacks, steal credentials or session tokens, and inject/replay queries, leading to unauthorized data access.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.mongodb.com/docs/atlas/setup-cluster-security/#encryption-in-transit", + "https://www.mongodb.com/docs/compass/connect/advanced-connection-options/tls-ssl-connection/", + "https://www.mongodb.com/docs/manual/tutorial/configure-ssl-clients/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "curl -u \":\" --digest -H \"Content-Type: application/json\" -X PATCH \"https://cloud.mongodb.com/api/atlas/v1.5/groups//clusters/\" --data '{\"sslEnabled\": true}'", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to MongoDB Atlas and open the target project\n2. Go to Project Settings > Security\n3. Set Minimum TLS Protocol Version to TLS 1.2 (or higher)\n4. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable TLS for MongoDB Atlas clusters by setting sslEnabled to true in the cluster configuration.", - "Url": "https://www.mongodb.com/docs/atlas/setup-cluster-security/#encryption-in-transit" + "Text": "Enforce **TLS 1.2+** for all connections and keep it mandatory (`sslEnabled=true`).\n\nApply **zero trust** and **defense in depth**: use CA-signed certificates, disable `tlsInsecure` and similar bypasses, rotate certificates, and restrict access via private networking or trusted IP ranges.", + "Url": "https://hub.prowler.com/check/clusters_tls_enabled" } }, - "Categories": [], + "Categories": [ + "encryption" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/mongodbatlas/services/organizations/organizations_api_access_list_required/organizations_api_access_list_required.metadata.json b/prowler/providers/mongodbatlas/services/organizations/organizations_api_access_list_required/organizations_api_access_list_required.metadata.json index 16d93fb9c9..673345d983 100644 --- a/prowler/providers/mongodbatlas/services/organizations/organizations_api_access_list_required/organizations_api_access_list_required.metadata.json +++ b/prowler/providers/mongodbatlas/services/organizations/organizations_api_access_list_required/organizations_api_access_list_required.metadata.json @@ -1,30 +1,40 @@ { "Provider": "mongodbatlas", "CheckID": "organizations_api_access_list_required", - "CheckTitle": "Ensure organization requires API access list", + "CheckTitle": "Organization requires API operations to originate from an IP address on the API access list", "CheckType": [], "ServiceName": "organizations", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "MongoDBAtlasOrganization", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure organization requires API operations to originate from an IP Address added to the API access list", - "Risk": "Without API access list requirement, API operations can originate from any IP address, increasing the risk of unauthorized access", + "Description": "**MongoDB Atlas organizations** can require Atlas Administration API requests to originate only from IPs or CIDRs listed in the organization's **API access list** (`apiAccessListRequired`).", + "Risk": "Without IP allowlisting, the Atlas Administration API is reachable from any network. A leaked token can be used from arbitrary hosts to:\n- change org/project settings and users\n- create, modify, or delete clusters\nThis undermines **integrity** and **availability** and expands the blast radius of credential compromise.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.mongodb.com/docs/atlas/access/orgs-create-view-edit-delete/", + "https://www.mongodb.com/docs/atlas/configure-api-access/", + "https://www.mongodb.com/docs/atlas/configure-api-access-mult-org/", + "https://www.mongodb.com/docs/atlas/tutorial/manage-organizations/", + "https://www.mongodb.com/docs/atlas/security/ip-access-list/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "curl --digest -u \":\" -H \"Content-Type: application/json\" -X PATCH \"https://cloud.mongodb.com/api/atlas/v2/orgs//settings\" -d '{\"apiAccessListRequired\":true}'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In the MongoDB Atlas UI, open the target organization and go to Organization Settings\n2. Toggle \"Require IP Access List for the Atlas Administration API\" to On\n3. Click Save", + "Terraform": "```hcl\nresource \"mongodbatlas_organization_settings\" \"\" {\n org_id = \"\"\n\n api_access_list_required = true # Critical: Enforces that Atlas Admin API calls must originate from IPs on the org's API access list\n}\n```" }, "Recommendation": { - "Text": "Enable API access list requirement for the organization by setting apiAccessListRequired to true in the organization settings.", - "Url": "https://www.mongodb.com/docs/atlas/security/ip-access-list/" + "Text": "Require the org **API access list** and restrict it to trusted, fixed egress IPs/CIDRs. Enforce **least privilege** on service accounts, prefer short-lived tokens, rotate secrets, and review entries regularly. Pair with **MFA/SSO** and monitoring for **defense in depth**.", + "Url": "https://hub.prowler.com/check/organizations_api_access_list_required" } }, - "Categories": [], + "Categories": [ + "internet-exposed", + "trust-boundaries" + ], "DependsOn": [], "RelatedTo": [], "Notes": "If you are running this check from Prowler Cloud, you will need to add our IP to the API access list of your API Key and then enable apiAccessListRequired to make this check pass." diff --git a/prowler/providers/mongodbatlas/services/organizations/organizations_mfa_required/organizations_mfa_required.metadata.json b/prowler/providers/mongodbatlas/services/organizations/organizations_mfa_required/organizations_mfa_required.metadata.json index e2b5d0dfb5..30546359de 100644 --- a/prowler/providers/mongodbatlas/services/organizations/organizations_mfa_required/organizations_mfa_required.metadata.json +++ b/prowler/providers/mongodbatlas/services/organizations/organizations_mfa_required/organizations_mfa_required.metadata.json @@ -1,30 +1,35 @@ { "Provider": "mongodbatlas", "CheckID": "organizations_mfa_required", - "CheckTitle": "Ensure organization requires MFA", + "CheckTitle": "Organization requires multi-factor authentication for user access", "CheckType": [], "ServiceName": "organizations", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "MongoDBAtlasOrganization", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure organization requires users to set up Multi-Factor Authentication (MFA) before accessing the organization", - "Risk": "Without MFA requirement, user accounts are vulnerable to credential-based attacks and unauthorized access", + "Description": "**MongoDB Atlas organization** mandates **multi-factor authentication** for all members before accessing organization resources (`multi_factor_auth_required`).", + "Risk": "Without enforced **MFA**, compromised passwords can grant organization access, enabling:\n- Data exposure (confidentiality)\n- Unauthorized configuration or role changes (integrity)\n- Resource deletion or disruption (availability)\nIncreases success of phishing, brute force, and session takeover.", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.mongodb.com/docs/atlas/security-multi-factor-authentication/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "curl --digest -u \":\" -H \"Content-Type: application/json\" -X PATCH \"https://cloud.mongodb.com/api/atlas/v2/orgs/\" -d '{\"multiFactorAuthRequired\":true}'", "NativeIaC": "", - "Other": "https://www.mongodb.com/docs/atlas/security-multi-factor-authentication/", + "Other": "1. Sign in to MongoDB Atlas\n2. Select the target Organization (top-left org selector)\n3. Go to Organization Settings > Security > Multi-Factor Authentication\n4. Enable \"Require Multi-Factor Authentication\" for the organization\n5. Click Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable MFA requirement for the organization by setting multiFactorAuthRequired to true in the organization settings.", - "Url": "https://www.mongodb.com/docs/atlas/security-multi-factor-authentication/" + "Text": "Enforce org-wide **MFA** (`multi_factor_auth_required`). Prefer **phishing-resistant factors** (security keys/biometrics) over SMS, and require at least two methods for recovery. Integrate with **SSO**, apply **least privilege**, and block access until all users enroll.", + "Url": "https://hub.prowler.com/check/organizations_mfa_required" } }, - "Categories": [], + "Categories": [ + "identity-access" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/mongodbatlas/services/organizations/organizations_security_contact_defined/organizations_security_contact_defined.metadata.json b/prowler/providers/mongodbatlas/services/organizations/organizations_security_contact_defined/organizations_security_contact_defined.metadata.json index 6cd75c66d9..8d29a6dde4 100644 --- a/prowler/providers/mongodbatlas/services/organizations/organizations_security_contact_defined/organizations_security_contact_defined.metadata.json +++ b/prowler/providers/mongodbatlas/services/organizations/organizations_security_contact_defined/organizations_security_contact_defined.metadata.json @@ -1,30 +1,35 @@ { "Provider": "mongodbatlas", "CheckID": "organizations_security_contact_defined", - "CheckTitle": "Ensure organization has a Security Contact defined", + "CheckTitle": "Organization has a security contact defined", "CheckType": [], "ServiceName": "organizations", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "MongoDBAtlasOrganization", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure organization has a security contact defined to receive security-related notifications", - "Risk": "Without a security contact, the organization may not receive important security notifications and alerts", + "Description": "**MongoDB Atlas organizations** specify a designated **security contact** email to receive **security notifications** and advisories.\n\nAssesses whether this contact is configured at the organization level.", + "Risk": "Missing or stale **security contact** causes critical advisories and incident alerts to be missed or delayed, slowing containment and patching. This elevates risks to **confidentiality** (undetected data access), **integrity** (malicious changes persist), and **availability** (abuse or outages escalate).", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.mongodb.com/docs/atlas/tutorial/manage-organization-settings/#add-security-contact-information" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "curl --digest -u \":\" -H \"Content-Type: application/json\" -X PATCH \"https://cloud.mongodb.com/api/atlas/v2/orgs//settings\" -d '{\"securityContact\":\"\"}'", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to MongoDB Atlas\n2. In the top bar, select your organization from the Organizations menu\n3. Go to Organization Settings\n4. In Atlas Security Contact Information, click Edit\n5. Enter the security contact email and click Save", "Terraform": "" }, "Recommendation": { - "Text": "Set a security contact email address in the organization settings to receive security-related notifications.", - "Url": "https://www.mongodb.com/docs/atlas/tutorial/manage-organization-settings/#add-security-contact-information" + "Text": "Define a monitored distribution list as the **security contact**.\n\n- Ensure 24/7 coverage and escalation\n- Keep it current and test delivery\n- Integrate with IR/ticketing workflows\n- Apply **least privilege** and document ownership", + "Url": "https://hub.prowler.com/check/organizations_security_contact_defined" } }, - "Categories": [], + "Categories": [ + "resilience" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/mongodbatlas/services/organizations/organizations_service_account_secrets_expiration/organizations_service_account_secrets_expiration.metadata.json b/prowler/providers/mongodbatlas/services/organizations/organizations_service_account_secrets_expiration/organizations_service_account_secrets_expiration.metadata.json index 5fe2ca92b1..0d41d98dc8 100644 --- a/prowler/providers/mongodbatlas/services/organizations/organizations_service_account_secrets_expiration/organizations_service_account_secrets_expiration.metadata.json +++ b/prowler/providers/mongodbatlas/services/organizations/organizations_service_account_secrets_expiration/organizations_service_account_secrets_expiration.metadata.json @@ -1,30 +1,36 @@ { "Provider": "mongodbatlas", "CheckID": "organizations_service_account_secrets_expiration", - "CheckTitle": "Ensure organization has maximum period expiration for Admin API Service Account Secrets", + "CheckTitle": "Organization has maximum validity period of 8 hours or less for Admin API Service Account secrets", "CheckType": [], "ServiceName": "organizations", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "MongoDBAtlasOrganization", + "Severity": "high", + "ResourceType": "NotDefined", "ResourceGroup": "governance", - "Description": "Ensure organization has a maximum period before expiry for new Atlas Admin API Service Account secrets", - "Risk": "Without proper expiration limits, service account secrets may remain valid for extended periods, increasing security risks", + "Description": "**MongoDB Atlas organization** setting `maxServiceAccountSecretValidityInHours` caps the lifetime of new **Admin API service account secrets**. Evaluation focuses on whether a limit is configured and whether it is at or below the defined policy threshold (default `8` hours).", + "Risk": "Overlong or unset lifetimes for **Admin API service account secrets** expand the attack window. If leaked, an adversary can maintain API access to:\n- Exfiltrate data (confidentiality)\n- Alter org or project configs (integrity)\n- Disable services or change access controls (availability)", "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/2025-03-12/operation/operation-getorganizationsettings#operation-getorganizationsettings-200-body-application-vnd-atlas-2023-01-01-json-maxserviceaccountsecretvalidityinhours", + "https://mongodb.prakticum-team.ru/docs/atlas/tutorial/activity-feed/" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "curl --user \":\" --digest -H \"Content-Type: application/json\" -X PATCH \"https://cloud.mongodb.com/api/atlas/v2/orgs//settings\" --data '{\"maxServiceAccountSecretValidityInHours\":8}'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In the MongoDB Atlas UI, select your organization\n2. Go to Organization > Settings\n3. Set \"Maximum validity for Admin API Service Account secrets\" to 8 hours (or less)\n4. Click Save", + "Terraform": "```hcl\nresource \"mongodbatlas_organization_settings\" \"\" {\n org_id = \"\"\n\n max_service_account_secret_validity_in_hours = 8 # Critical: enforce <= 8 hours for Admin API Service Account secrets\n}\n```" }, "Recommendation": { - "Text": "Set maxServiceAccountSecretValidityInHours to 8 hours or less in the organization settings to ensure service account secrets expire regularly.", - "Url": "https://www.mongodb.com/docs/api/doc/atlas-admin-api-v2/2025-03-12/operation/operation-getorganizationsettings#operation-getorganizationsettings-200-body-application-vnd-atlas-2023-01-01-json-maxserviceaccountsecretvalidityinhours" + "Text": "Set a strict cap via `maxServiceAccountSecretValidityInHours` to `8` hours or less. Apply **least privilege** to service accounts, automate secret **rotation/revocation**, and monitor usage. Store secrets in a secure vault and limit where they can be used *e.g., network allowlisting* as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/organizations_service_account_secrets_expiration" } }, - "Categories": [], + "Categories": [ + "secrets" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" diff --git a/prowler/providers/mongodbatlas/services/projects/projects_auditing_enabled/projects_auditing_enabled.metadata.json b/prowler/providers/mongodbatlas/services/projects/projects_auditing_enabled/projects_auditing_enabled.metadata.json index a08da20986..e462a62999 100644 --- a/prowler/providers/mongodbatlas/services/projects/projects_auditing_enabled/projects_auditing_enabled.metadata.json +++ b/prowler/providers/mongodbatlas/services/projects/projects_auditing_enabled/projects_auditing_enabled.metadata.json @@ -1,13 +1,13 @@ { "Provider": "mongodbatlas", "CheckID": "projects_auditing_enabled", - "CheckTitle": "MongoDB Atlas project has database auditing enabled", + "CheckTitle": "Project has database auditing enabled", "CheckType": [], "ServiceName": "projects", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "MongoDBAtlasProject", + "ResourceType": "NotDefined", "ResourceGroup": "governance", "Description": "**MongoDB Atlas projects** with **database auditing** capture database operations and administrative events. The evaluation looks for an active audit configuration and, *when present*, notes any configured `audit_filter` that scopes which events are recorded.", "Risk": "Without auditing, critical actions lack traceability, reducing **detectability** and impeding **forensics**. Attackers can mask unauthorized reads/writes and privilege changes, threatening data **confidentiality** and **integrity**, and weakening non-repudiation and incident response.", diff --git a/prowler/providers/mongodbatlas/services/projects/projects_network_access_list_exposed_to_internet/projects_network_access_list_exposed_to_internet.metadata.json b/prowler/providers/mongodbatlas/services/projects/projects_network_access_list_exposed_to_internet/projects_network_access_list_exposed_to_internet.metadata.json index 663da41a6e..a3ab8c60a6 100644 --- a/prowler/providers/mongodbatlas/services/projects/projects_network_access_list_exposed_to_internet/projects_network_access_list_exposed_to_internet.metadata.json +++ b/prowler/providers/mongodbatlas/services/projects/projects_network_access_list_exposed_to_internet/projects_network_access_list_exposed_to_internet.metadata.json @@ -1,13 +1,13 @@ { "Provider": "mongodbatlas", "CheckID": "projects_network_access_list_exposed_to_internet", - "CheckTitle": "MongoDB Atlas project network access list has entries and excludes 0.0.0.0/0, ::/0, 0.0.0.0, and ::", + "CheckTitle": "Project network access list has entries and excludes 0.0.0.0/0, ::/0, 0.0.0.0, and ::", "CheckType": [], "ServiceName": "projects", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "MongoDBAtlasProject", + "ResourceType": "NotDefined", "ResourceGroup": "governance", "Description": "**MongoDB Atlas project network access list** configuration is evaluated for entries that allow access from anywhere (`0.0.0.0/0`, `::/0`, `0.0.0.0`, `::`) or for missing access lists, instead of restricting connections to specific IPs or CIDRs.", "Risk": "Internet-wide access enables scanning, brute force, and credential stuffing against database endpoints. A successful compromise can cause data exfiltration (**confidentiality**), unauthorized writes or drops (**integrity**), and service disruption or lockout (**availability**).", diff --git a/prowler/providers/nhn/lib/arguments/arguments.py b/prowler/providers/nhn/lib/arguments/arguments.py index d4925090e6..8f7c71fd7c 100644 --- a/prowler/providers/nhn/lib/arguments/arguments.py +++ b/prowler/providers/nhn/lib/arguments/arguments.py @@ -1,3 +1,6 @@ +SENSITIVE_ARGUMENTS = frozenset({"--nhn-password"}) + + def init_parser(self): """Init the NHN Provider CLI parser""" nhn_parser = self.subparsers.add_parser( diff --git a/prowler/providers/openstack/lib/arguments/arguments.py b/prowler/providers/openstack/lib/arguments/arguments.py index e77e129d1d..459012c4ec 100644 --- a/prowler/providers/openstack/lib/arguments/arguments.py +++ b/prowler/providers/openstack/lib/arguments/arguments.py @@ -1,5 +1,7 @@ from argparse import Namespace +SENSITIVE_ARGUMENTS = frozenset({"--os-password"}) + def init_parser(self): """Initialize the OpenStack provider CLI parser.""" diff --git a/prowler/providers/openstack/services/image/image_signature_verification_enabled/image_signature_verification_enabled.metadata.json b/prowler/providers/openstack/services/image/image_signature_verification_enabled/image_signature_verification_enabled.metadata.json index c9630e75aa..84b0e20799 100644 --- a/prowler/providers/openstack/services/image/image_signature_verification_enabled/image_signature_verification_enabled.metadata.json +++ b/prowler/providers/openstack/services/image/image_signature_verification_enabled/image_signature_verification_enabled.metadata.json @@ -9,7 +9,7 @@ "Severity": "high", "ResourceType": "OS::Glance::WebImage", "ResourceGroup": "storage", - "Description": "**OpenStack images** are evaluated to verify that all **four signature properties** are configured: `img_signature`, `img_signature_hash_method`, `img_signature_key_type`, and `img_signature_certificate_uuid`. Signed images allow Nova to verify image integrity before booting, detecting tampering or corruption. Best practices recommend signing all production images using Barbican-managed certificates.", + "Description": "**OpenStack images** are evaluated to verify that all **four signature properties** are configured: `img_signature`, `img_signature_hash_method`, `img_signature_key_type`, and `img_signature_certificate_uuid`. Signed images allow Nova to verify integrity before booting, detecting tampering or corruption.", "Risk": "Unsigned images can be tampered with to inject backdoors, malware, or rootkits without detection. Without signature verification, compromised storage backends or man-in-the-middle attacks can modify images between upload and boot. Nova cannot verify the integrity of unsigned images, allowing corrupted or malicious images to be launched.", "RelatedUrl": "", "AdditionalURLs": [ diff --git a/prowler/providers/openstack/services/networking/networking_security_group_allows_all_ingress_from_internet/networking_security_group_allows_all_ingress_from_internet.metadata.json b/prowler/providers/openstack/services/networking/networking_security_group_allows_all_ingress_from_internet/networking_security_group_allows_all_ingress_from_internet.metadata.json index 473dc9602d..79998af96c 100644 --- a/prowler/providers/openstack/services/networking/networking_security_group_allows_all_ingress_from_internet/networking_security_group_allows_all_ingress_from_internet.metadata.json +++ b/prowler/providers/openstack/services/networking/networking_security_group_allows_all_ingress_from_internet/networking_security_group_allows_all_ingress_from_internet.metadata.json @@ -10,7 +10,7 @@ "ResourceType": "OS::Neutron::SecurityGroup", "ResourceGroup": "network", "Description": "**OpenStack security groups** are evaluated to verify that no rule allows **all ingress traffic** (any protocol, any port) from the Internet (0.0.0.0/0 or ::/0). A rule with no protocol and no port restriction is effectively a \"permit any\" firewall rule, completely bypassing network-level access controls. This is the most permissive possible configuration and should never be used in production.", - "Risk": "Allowing all inbound traffic from the Internet exposes every service running on the instance to unauthorized access. Attackers can discover and exploit any listening service including databases, management interfaces, internal APIs, and debugging tools. This bypasses defense-in-depth and is equivalent to having no firewall. Combined with misconfigurations or unpatched services, it enables initial access, lateral movement, data exfiltration, and full infrastructure compromise.", + "Risk": "Allowing all inbound traffic from the Internet exposes every service on the instance to unauthorized access. Attackers can exploit any listening service including databases, management interfaces, and internal APIs. This bypasses defense-in-depth and enables initial access, lateral movement, data exfiltration, and infrastructure compromise.", "RelatedUrl": "", "AdditionalURLs": [ "https://docs.openstack.org/neutron/latest/admin/intro-os-networking.html", diff --git a/prowler/providers/openstack/services/objectstorage/__init__.py b/prowler/providers/openstack/services/objectstorage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_client.py b/prowler/providers/openstack/services/objectstorage/objectstorage_client.py new file mode 100644 index 0000000000..b858f90176 --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorage, +) + +objectstorage_client = ObjectStorage(Provider.get_global_provider()) diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/__init__.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared.metadata.json b/prowler/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared.metadata.json new file mode 100644 index 0000000000..02d66edfa5 --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "openstack", + "CheckID": "objectstorage_container_acl_not_globally_shared", + "CheckTitle": "Object storage container read ACL is not globally shared", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OS::Swift::Container", + "ResourceGroup": "storage", + "Description": "**OpenStack Swift containers** are evaluated to verify that the **read ACL** does not use `*:*` (global sharing). The `*:*` ACL grants read access to all authenticated users from any project in the OpenStack deployment, violating project isolation and the principle of least privilege.", + "Risk": "Containers with `*:*` read ACL are accessible to **every authenticated user** in the OpenStack deployment, regardless of their project. This breaks **multi-tenant isolation**, exposing data to users in other projects. **Compromised credentials** from any project can access the container's contents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.openstack.org/swift/latest/overview_acl.html", + "https://docs.openstack.org/security-guide/object-storage.html" + ], + "Remediation": { + "Code": { + "CLI": "swift post --read-acl ':*'", + "NativeIaC": "", + "Other": "1. Navigate to **Object Store > Containers**\n2. Select the container with global read ACL\n3. Edit the container metadata\n4. Replace `*:*` with specific project-scoped ACLs\n5. Save changes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Replace `*:*` read ACLs with **project-scoped ACLs** (e.g., `project-id:*` or `project-id:user-id`). Use the **most restrictive ACL** that meets access requirements. Implement regular ACL audits to detect overly permissive configurations. Consider using **Keystone role-based access control** for fine-grained permissions.", + "Url": "https://hub.prowler.com/check/objectstorage_container_acl_not_globally_shared" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The `*:*` ACL means 'any user in any project'. This is different from `.r:*` which grants anonymous (unauthenticated) access. Both are overly permissive but target different audiences: `*:*` targets all authenticated users while `.r:*` targets everyone including unauthenticated users." +} diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared.py new file mode 100644 index 0000000000..e5c21616d8 --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared.py @@ -0,0 +1,29 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportOpenStack +from prowler.providers.openstack.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_container_acl_not_globally_shared(Check): + """Ensure object storage container read ACL does not use global sharing.""" + + def execute(self) -> List[CheckReportOpenStack]: + findings: List[CheckReportOpenStack] = [] + + for container in objectstorage_client.containers: + report = CheckReportOpenStack(metadata=self.metadata(), resource=container) + acl_entries = [entry.strip() for entry in container.read_ACL.split(",")] + if "*:*" in acl_entries or "*" in acl_entries: + report.status = "FAIL" + report.status_extended = f"Container {container.name} has globally shared read ACL (*:*) allowing all authenticated users from any project." + else: + report.status = "PASS" + report.status_extended = ( + f"Container {container.name} read ACL is not globally shared." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/__init__.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled.metadata.json b/prowler/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled.metadata.json new file mode 100644 index 0000000000..446d01a74b --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "openstack", + "CheckID": "objectstorage_container_listing_disabled", + "CheckTitle": "Object storage container listings are not publicly accessible", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OS::Swift::Container", + "ResourceGroup": "storage", + "Description": "**OpenStack Swift containers** are evaluated to verify that **public container listings** are disabled. The `.rlistings` ACL allows users with read access to list all objects within the container. When combined with `.r:*`, this enables unauthenticated users to enumerate all objects, facilitating targeted data exfiltration.", + "Risk": "Containers with `.rlistings` enabled allow attackers to enumerate all object names, sizes, and modification dates. This **metadata exposure** aids **targeted attacks**, reveals application structure, and can expose sensitive file names. Combined with public read access, it enables complete **data exfiltration**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.openstack.org/swift/latest/overview_acl.html", + "https://docs.openstack.org/security-guide/object-storage.html" + ], + "Remediation": { + "Code": { + "CLI": "swift post --read-acl ''", + "NativeIaC": "", + "Other": "1. Navigate to **Object Store > Containers**\n2. Select the container with public listing\n3. Edit the container metadata\n4. Remove `.rlistings` from the Read ACL\n5. Save changes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove `.rlistings` from container read ACLs to prevent **public object enumeration**. Listings should only be available to **authenticated project members**. If external access is needed, implement application-level access control with authenticated APIs.", + "Url": "https://hub.prowler.com/check/objectstorage_container_listing_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The `.rlistings` ACL controls whether container listings (GET on container) are allowed. Even without `.rlistings`, individual objects may still be readable if `.r:*` is set. Both ACLs should be removed for full protection." +} diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled.py new file mode 100644 index 0000000000..a541b9866e --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled.py @@ -0,0 +1,32 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportOpenStack +from prowler.providers.openstack.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_container_listing_disabled(Check): + """Ensure object storage container object listings are not publicly accessible.""" + + def execute(self) -> List[CheckReportOpenStack]: + findings: List[CheckReportOpenStack] = [] + + for container in objectstorage_client.containers: + report = CheckReportOpenStack(metadata=self.metadata(), resource=container) + acl_entries = [entry.strip() for entry in container.read_ACL.split(",")] + if ".rlistings" in acl_entries: + report.status = "FAIL" + report.status_extended = f"Container {container.name} has public listing enabled (.rlistings) allowing anonymous object enumeration." + elif "*:*" in acl_entries or "*" in acl_entries: + report.status = "FAIL" + report.status_extended = f"Container {container.name} has listing enabled via global read ACL (*:*) allowing all authenticated users to list objects." + else: + report.status = "PASS" + report.status_extended = ( + f"Container {container.name} does not have public listing enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/__init__.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..b3a39bd3f8 --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "openstack", + "CheckID": "objectstorage_container_metadata_sensitive_data", + "CheckTitle": "Object storage container metadata does not contain sensitive data", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "OS::Swift::Container", + "ResourceGroup": "storage", + "Description": "**OpenStack Swift container metadata** is evaluated to detect **sensitive data** such as passwords, API keys, secrets, and private keys. Container metadata is accessible to any user with read access to the container. Storing secrets in metadata exposes them to unauthorized access and credential theft.", + "Risk": "Container metadata containing **sensitive data** exposes credentials to any user with container read access. Attackers with read permissions can extract **passwords**, **API keys**, and **private keys**. Stolen credentials enable unauthorized access to other systems, **data exfiltration**, and **privilege escalation**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.openstack.org/swift/latest/overview_acl.html", + "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html" + ], + "Remediation": { + "Code": { + "CLI": "swift post --meta ':'", + "NativeIaC": "", + "Other": "1. Identify containers with sensitive metadata\n2. Remove sensitive metadata keys using CLI\n3. Rotate exposed credentials immediately\n4. Store secrets in Barbican or external secrets manager instead", + "Terraform": "" + }, + "Recommendation": { + "Text": "Never store secrets in container metadata. Use **Barbican** (OpenStack Key Manager), **Vault**, or external secrets management instead. Remove existing sensitive metadata and **rotate any exposed credentials**. Implement metadata policies to prevent future secret storage.", + "Url": "https://hub.prowler.com/check/objectstorage_container_metadata_sensitive_data" + } + }, + "Categories": [ + "threat-detection", + "secrets", + "encryption" + ], + "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." +} 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 new file mode 100644 index 0000000000..94d281bf32 --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data.py @@ -0,0 +1,64 @@ +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.providers.openstack.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_container_metadata_sensitive_data(Check): + """Ensure object storage container metadata does not contain sensitive data like passwords or API keys.""" + + def execute(self) -> List[CheckReportOpenStack]: + findings: List[CheckReportOpenStack] = [] + secrets_ignore_patterns = objectstorage_client.audit_config.get( + "secrets_ignore_patterns", [] + ) + + for container in objectstorage_client.containers: + report = CheckReportOpenStack(metadata=self.metadata(), resource=container) + report.status = "PASS" + report.status_extended = ( + f"Container {container.name} metadata does not contain sensitive data." + ) + + 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 detect_secrets_output: + # Map line numbers back to metadata keys using the parallel list + # Line numbering: line 1 = "{", line 2 = first key-value, etc. + secrets_string = ", ".join( + [ + f"{secret['type']} in metadata key '{original_metadata_keys[secret['line_number'] - 2]}'" + for secret in detect_secrets_output + if 0 + <= secret["line_number"] - 2 + < len(original_metadata_keys) + ] + ) + report.status = "FAIL" + report.status_extended = f"Container {container.name} metadata contains potential secrets -> {secrets_string}." + else: + report.status_extended = f"Container {container.name} has no metadata (no sensitive data exposure risk)." + + findings.append(report) + + return findings diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/__init__.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled.metadata.json b/prowler/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled.metadata.json new file mode 100644 index 0000000000..f15becbcf5 --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "openstack", + "CheckID": "objectstorage_container_public_read_acl_disabled", + "CheckTitle": "Object storage containers do not grant anonymous read access", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OS::Swift::Container", + "ResourceGroup": "storage", + "Description": "**OpenStack Swift containers** are evaluated to verify that **anonymous read access** is not granted. The `.r:*` ACL allows any unauthenticated user on the internet to read objects in the container. This is a critical exposure that can lead to data leaks, especially when containers hold sensitive information such as backups, logs, or application data.", + "Risk": "Containers with `.r:*` in the read ACL are **publicly accessible** without authentication. Attackers can enumerate and download all objects, leading to **data breaches**, **credential exposure**, and **compliance violations**. Public containers are frequently targeted by automated scanners.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.openstack.org/swift/latest/overview_acl.html", + "https://docs.openstack.org/security-guide/object-storage.html" + ], + "Remediation": { + "Code": { + "CLI": "swift post --read-acl ''", + "NativeIaC": "", + "Other": "1. Navigate to **Object Store > Containers**\n2. Select the container with public read ACL\n3. Edit the container metadata\n4. Remove the `.r:*` entry from the Read ACL\n5. Save changes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove `.r:*` from container read ACLs to prevent **anonymous access**. Use **project-scoped ACLs** (e.g., `project-id:*`) or specific user ACLs instead. If public access is required, consider using a **CDN** or **signed URLs** with time-limited tokens.", + "Url": "https://hub.prowler.com/check/objectstorage_container_public_read_acl_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Swift ACLs use a specific syntax where `.r:*` grants read access to all referrers (anonymous access). The `.r:` prefix indicates referrer-based ACL. This check specifically looks for the wildcard referrer pattern." +} diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled.py new file mode 100644 index 0000000000..6a3b5c03ec --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled.py @@ -0,0 +1,29 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportOpenStack +from prowler.providers.openstack.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_container_public_read_acl_disabled(Check): + """Ensure object storage containers do not grant anonymous read access.""" + + def execute(self) -> List[CheckReportOpenStack]: + findings: List[CheckReportOpenStack] = [] + + for container in objectstorage_client.containers: + report = CheckReportOpenStack(metadata=self.metadata(), resource=container) + acl_entries = [entry.strip() for entry in container.read_ACL.split(",")] + if ".r:*" in acl_entries: + report.status = "FAIL" + report.status_extended = f"Container {container.name} has public read ACL (.r:*) allowing anonymous access." + else: + report.status = "PASS" + report.status_extended = ( + f"Container {container.name} does not have public read ACL." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/__init__.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled.metadata.json b/prowler/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled.metadata.json new file mode 100644 index 0000000000..35925433c5 --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "openstack", + "CheckID": "objectstorage_container_sync_not_enabled", + "CheckTitle": "Object storage containers do not have container sync enabled", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OS::Swift::Container", + "ResourceGroup": "storage", + "Description": "**OpenStack Swift containers** are evaluated to verify that **container sync** is not configured. Container sync replicates objects to another Swift cluster, potentially outside the organization's control. Unauthorized sync configurations can be used for data exfiltration to external clusters.", + "Risk": "Container sync replicates all objects to a **remote Swift cluster**. If misconfigured or set up by an attacker, sensitive data is continuously **exfiltrated** to an external location. The sync operates automatically and silently, making it difficult to detect ongoing **data theft**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.openstack.org/swift/latest/overview_container_sync.html", + "https://docs.openstack.org/security-guide/object-storage.html" + ], + "Remediation": { + "Code": { + "CLI": "swift post -H 'X-Container-Sync-To:' -H 'X-Container-Sync-Key:'", + "NativeIaC": "", + "Other": "1. Identify containers with sync enabled\n2. Remove sync configuration using CLI\n3. Verify sync is disabled: `swift stat `\n4. Review sync logs for unauthorized data transfers\n5. Investigate who configured the sync and when", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable container sync unless explicitly required for **disaster recovery**. If sync is needed, ensure the target cluster is within your organization's **trust boundary**. Monitor sync configurations for unauthorized changes. Implement **RBAC policies** to restrict who can configure container sync.", + "Url": "https://hub.prowler.com/check/objectstorage_container_sync_not_enabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Container sync is configured via X-Container-Sync-To (target URL) and X-Container-Sync-Key (shared secret) headers. The sync middleware must be enabled in the Swift pipeline for sync to function. This check flags any container with a non-empty sync_to value." +} diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled.py new file mode 100644 index 0000000000..ac7545e69e --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled.py @@ -0,0 +1,28 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportOpenStack +from prowler.providers.openstack.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_container_sync_not_enabled(Check): + """Ensure object storage containers do not have container sync configured.""" + + def execute(self) -> List[CheckReportOpenStack]: + findings: List[CheckReportOpenStack] = [] + + for container in objectstorage_client.containers: + report = CheckReportOpenStack(metadata=self.metadata(), resource=container) + if container.sync_to: + report.status = "FAIL" + report.status_extended = f"Container {container.name} has container sync enabled (sync target: {container.sync_to})." + else: + report.status = "PASS" + report.status_extended = ( + f"Container {container.name} does not have container sync enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/__init__.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled.metadata.json b/prowler/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled.metadata.json new file mode 100644 index 0000000000..568e7c252f --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "openstack", + "CheckID": "objectstorage_container_versioning_enabled", + "CheckTitle": "Object storage containers have versioning enabled", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "OS::Swift::Container", + "ResourceGroup": "storage", + "Description": "**OpenStack Swift containers** are evaluated to verify that **object versioning** is enabled. Versioning preserves previous versions of objects when they are overwritten or deleted, enabling recovery from accidental modifications, ransomware attacks, and data corruption.", + "Risk": "Without versioning, overwritten or deleted objects **cannot be recovered**. Accidental deletions, **ransomware** encrypting objects, or malicious modifications result in **permanent data loss**. Versioning provides a safety net for forensic investigation and disaster recovery.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.openstack.org/swift/latest/overview_object_versioning.html", + "https://docs.openstack.org/security-guide/object-storage.html" + ], + "Remediation": { + "Code": { + "CLI": "swift post -H 'X-Versions-Location: '", + "NativeIaC": "", + "Other": "1. Create a versions container: `swift post _versions`\n2. Enable versioning (stack mode): `swift post -H 'X-Versions-Location: _versions'`\n Or enable versioning (history mode): `swift post -H 'X-History-Location: _versions'`\n3. Verify: `swift stat ` shows Versions header", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **object versioning** on all containers storing important data. Create a dedicated versions container and set the `X-Versions-Location` (stack mode) or `X-History-Location` (history mode) header. Implement **lifecycle policies** to manage version retention and storage costs. Combine with container backups for comprehensive data protection.", + "Url": "https://hub.prowler.com/check/objectstorage_container_versioning_enabled" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Swift supports two versioning modes: X-Versions-Location (stack mode, stores versions in a separate container) and X-History-Location (history mode). This check verifies that either versions_location or history_location is set, indicating versioning is configured." +} diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled.py new file mode 100644 index 0000000000..f2e811093f --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled.py @@ -0,0 +1,30 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportOpenStack +from prowler.providers.openstack.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_container_versioning_enabled(Check): + """Ensure object storage containers have versioning enabled for data protection.""" + + def execute(self) -> List[CheckReportOpenStack]: + findings: List[CheckReportOpenStack] = [] + + for container in objectstorage_client.containers: + report = CheckReportOpenStack(metadata=self.metadata(), resource=container) + if container.versioning_enabled: + report.status = "PASS" + location = container.versions_location or container.history_location + mode = "versions" if container.versions_location else "history" + report.status_extended = f"Container {container.name} has versioning enabled ({mode} location: {location})." + else: + report.status = "FAIL" + report.status_extended = ( + f"Container {container.name} does not have versioning enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/__init__.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted.metadata.json b/prowler/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted.metadata.json new file mode 100644 index 0000000000..fa2af1334a --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "openstack", + "CheckID": "objectstorage_container_write_acl_restricted", + "CheckTitle": "Object storage container write ACL is restricted", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "OS::Swift::Container", + "ResourceGroup": "storage", + "Description": "**OpenStack Swift containers** are evaluated to verify that **write access** is restricted. The `*:*` write ACL allows any authenticated user from any project to create, modify, or delete objects. The `*` write ACL similarly grants unrestricted write access. Both configurations violate the principle of least privilege.", + "Risk": "Containers with **unrestricted write ACLs** allow any authenticated OpenStack user to upload, overwrite, or delete objects. This enables **data tampering**, **malware injection**, **ransomware attacks** (encrypting objects), and **resource abuse** (cryptomining payloads). Compromised credentials from any project can be used to modify data.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.openstack.org/swift/latest/overview_acl.html", + "https://docs.openstack.org/security-guide/object-storage.html" + ], + "Remediation": { + "Code": { + "CLI": "swift post --write-acl ':'", + "NativeIaC": "", + "Other": "1. Navigate to **Object Store > Containers**\n2. Select the container with unrestricted write ACL\n3. Edit the container metadata\n4. Replace `*:*` or `*` with specific `project-id:user-id` entries\n5. Save changes", + "Terraform": "" + }, + "Recommendation": { + "Text": "Restrict write ACLs to specific project and user combinations (e.g., `project-id:user-id`). Never use `*:*` or `*` as write ACLs. Implement **RBAC policies** to control write access. Regularly audit container ACLs to detect **overly permissive configurations**.", + "Url": "https://hub.prowler.com/check/objectstorage_container_write_acl_restricted" + } + }, + "Categories": [ + "internet-exposed", + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The `*:*` ACL means 'any user in any project' while `*` alone also grants unrestricted access. Both are dangerous for write operations as they allow data modification by any authenticated user in the OpenStack deployment." +} diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted.py b/prowler/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted.py new file mode 100644 index 0000000000..0693ebedce --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted.py @@ -0,0 +1,29 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportOpenStack +from prowler.providers.openstack.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_container_write_acl_restricted(Check): + """Ensure object storage container write ACL does not allow all authenticated users.""" + + def execute(self) -> List[CheckReportOpenStack]: + findings: List[CheckReportOpenStack] = [] + + for container in objectstorage_client.containers: + report = CheckReportOpenStack(metadata=self.metadata(), resource=container) + acl_entries = [entry.strip() for entry in container.write_ACL.split(",")] + if "*:*" in acl_entries or "*" in acl_entries: + report.status = "FAIL" + report.status_extended = f"Container {container.name} has unrestricted write ACL allowing all authenticated users to write." + else: + report.status = "PASS" + report.status_extended = ( + f"Container {container.name} has restricted write ACL." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/openstack/services/objectstorage/objectstorage_service.py b/prowler/providers/openstack/services/objectstorage/objectstorage_service.py new file mode 100644 index 0000000000..6a2a73d5a0 --- /dev/null +++ b/prowler/providers/openstack/services/objectstorage/objectstorage_service.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + +from openstack import exceptions as openstack_exceptions + +from prowler.lib.logger import logger +from prowler.providers.openstack.lib.service.service import OpenStackService + + +class ObjectStorage(OpenStackService): + """Service wrapper using openstacksdk object-store APIs.""" + + def __init__(self, provider) -> None: + super().__init__(__class__.__name__, provider) + self.containers: List[ObjectStorageContainer] = [] + self._list_containers() + + def _list_containers(self) -> None: + """List all object storage containers across all audited regions.""" + logger.info("ObjectStorage - Listing containers...") + for region, conn in self.regional_connections.items(): + try: + for container in conn.object_store.containers(): + # The list API only returns name/count/bytes; HEAD each + # container to retrieve ACLs, metadata, and versioning info. + try: + detail = conn.object_store.get_container_metadata( + getattr(container, "name", "") + ) + except Exception as head_error: + logger.warning( + f"Could not HEAD container {getattr(container, 'name', '')}: {head_error}" + ) + detail = container + + metadata = getattr(detail, "metadata", None) or {} + + # Extract versioning info (Swift supports two modes) + versions_location = getattr(detail, "versions_location", "") or "" + history_location = getattr(detail, "history_location", "") or "" + versioning_enabled = bool(versions_location or history_location) + + self.containers.append( + ObjectStorageContainer( + id=getattr(container, "name", ""), + name=getattr(container, "name", ""), + region=region, + project_id=self.project_id, + object_count=getattr(detail, "count", 0), + bytes_used=getattr(detail, "bytes", 0), + read_ACL=getattr(detail, "read_ACL", "") or "", + write_ACL=getattr(detail, "write_ACL", "") or "", + versioning_enabled=versioning_enabled, + versions_location=versions_location, + history_location=history_location, + sync_to=getattr(detail, "sync_to", "") or "", + sync_key=getattr(detail, "sync_key", "") or "", + metadata=metadata if isinstance(metadata, dict) else {}, + ) + ) + except openstack_exceptions.SDKException as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- " + f"Failed to list object storage containers in region {region}: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- " + f"Unexpected error listing object storage containers in region {region}: {error}" + ) + + +@dataclass +class ObjectStorageContainer: + """Represents an OpenStack Swift container.""" + + id: str + name: str + region: str + project_id: str + object_count: int + bytes_used: int + read_ACL: str + write_ACL: str + versioning_enabled: bool + versions_location: str + history_location: str + sync_to: str + sync_key: str + metadata: Dict[str, str] diff --git a/prowler/providers/oraclecloud/config.py b/prowler/providers/oraclecloud/config.py index 9e428672d5..0c20c44f57 100644 --- a/prowler/providers/oraclecloud/config.py +++ b/prowler/providers/oraclecloud/config.py @@ -65,5 +65,16 @@ OCI_GOVERNMENT_REGIONS = { "us-luke-1": "US Gov East", } +# OCI Defense Regions +OCI_US_DOD_REGIONS = { + "us-gov-ashburn-1": "US DoD East (Ashburn)", + "us-gov-chicago-1": "US DoD North (Chicago)", + "us-gov-phoenix-1": "US DoD West (Phoenix)", +} + # All OCI Regions -OCI_REGIONS = {**OCI_COMMERCIAL_REGIONS, **OCI_GOVERNMENT_REGIONS} +OCI_REGIONS = { + **OCI_COMMERCIAL_REGIONS, + **OCI_GOVERNMENT_REGIONS, + **OCI_US_DOD_REGIONS, +} diff --git a/prowler/providers/oraclecloud/lib/arguments/arguments.py b/prowler/providers/oraclecloud/lib/arguments/arguments.py index 66971d0a58..a736b7a1af 100644 --- a/prowler/providers/oraclecloud/lib/arguments/arguments.py +++ b/prowler/providers/oraclecloud/lib/arguments/arguments.py @@ -44,8 +44,9 @@ def init_parser(self): oci_regions_subparser = oci_parser.add_argument_group("OCI Regions") oci_regions_subparser.add_argument( "--region", - "-r", - nargs="?", + "--filter-region", + "-f", + nargs="+", help="OCI region to run Prowler against. If not specified, all subscribed regions will be audited", choices=list(OCI_REGIONS.keys()), ) diff --git a/prowler/providers/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index 3ff83e7c22..c5abaf3929 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -62,7 +62,7 @@ class OraclecloudProvider(Provider): _identity: OCIIdentityInfo _session: OCISession _audit_config: dict - _regions: list = [] + _regions: set = set() _compartments: list = [] _mutelist: OCIMutelist audit_metadata: Audit_Metadata @@ -71,7 +71,7 @@ class OraclecloudProvider(Provider): self, oci_config_file: str = None, profile: str = None, - region: str = None, + region: set = set(), compartment_ids: list = None, config_path: str = None, config_content: dict = None, @@ -92,7 +92,7 @@ class OraclecloudProvider(Provider): Args: - oci_config_file: The path to the OCI config file. - profile: The name of the OCI CLI profile to use. - - region: The OCI region to audit. + - region: The OCI region(s) to audit. - compartment_ids: A list of compartment OCIDs to audit. - config_path: The path to the Prowler configuration file. - config_content: The content of the configuration file. @@ -127,6 +127,11 @@ class OraclecloudProvider(Provider): logger.info("Initializing OCI provider ...") + # Check if the configuration is scanning a single region + single_region = None + if region: + single_region = list(region)[0] if len(region) == 1 else None + # Setup OCI Session logger.info("Setting up OCI session ...") self._session = self.setup_session( @@ -138,7 +143,7 @@ class OraclecloudProvider(Provider): key_file=key_file, key_content=key_content, tenancy=tenancy, - region=region, + region=single_region, pass_phrase=pass_phrase, ) @@ -148,7 +153,7 @@ class OraclecloudProvider(Provider): logger.info("Validating OCI credentials ...") self._identity = self.set_identity( session=self._session, - region=region, + region=single_region, compartment_ids=compartment_ids, ) logger.info("OCI credentials validated") @@ -346,7 +351,6 @@ class OraclecloudProvider(Provider): try: config = oci.config.from_file(oci_config_file, profile) - oci.config.validate_config(config) # Check if using security token authentication if ( @@ -369,6 +373,9 @@ class OraclecloudProvider(Provider): token=token, private_key=private_key ) else: + # Only validate full config for API key auth + # (session auth doesn't require 'user' field) + oci.config.validate_config(config) logger.info( f"Using profile '{profile}' with API key authentication" ) @@ -531,47 +538,42 @@ class OraclecloudProvider(Provider): return True - def get_regions_to_audit(self, region: str = None) -> list: + def get_regions_to_audit(self, region_set: set = None) -> list: """ get_regions_to_audit returns the list of regions to audit. Args: - - region: The OCI region to audit. + - region: The OCI region(s) to audit. Returns: - list: The list of OCIRegion objects to audit. """ regions = [] - if region: - # Audit specific region - if region in OCI_REGIONS: - regions.append( - OCIRegion( - key=region, - name=OCI_REGIONS[region], - is_home_region=True, - ) + # Audit all subscribed regions + try: + # Create identity client with proper authentication handling + if self._session.signer: + identity_client = oci.identity.IdentityClient( + config=self._session.config, signer=self._session.signer ) else: - logger.warning(f"Invalid region: {region}. Using default region.") - else: - # Audit all subscribed regions - try: - # Create identity client with proper authentication handling - if self._session.signer: - identity_client = oci.identity.IdentityClient( - config=self._session.config, signer=self._session.signer - ) - else: - identity_client = oci.identity.IdentityClient( - config=self._session.config - ) - region_subscriptions = identity_client.list_region_subscriptions( - self._identity.tenancy_id - ).data + identity_client = oci.identity.IdentityClient( + config=self._session.config + ) + region_subscriptions = identity_client.list_region_subscriptions( + self._identity.tenancy_id + ).data - for region_sub in region_subscriptions: + # Check if auditing specific region or all + regions_check = ( + region_set + if region_set + else [sub.region_name for sub in region_subscriptions] + ) + + for region_sub in region_subscriptions: + if region_sub.region_name in regions_check: regions.append( OCIRegion( key=region_sub.region_name, @@ -581,22 +583,20 @@ class OraclecloudProvider(Provider): is_home_region=region_sub.is_home_region, ) ) - - logger.info(f"Found {len(regions)} subscribed regions") - - except Exception as error: - logger.warning( - f"Could not retrieve region subscriptions: {error}. Using configured region." - ) - # Fallback to configured region - config_region = self._session.config.get("region", "us-ashburn-1") - regions.append( - OCIRegion( - key=config_region, - name=OCI_REGIONS.get(config_region, config_region), - is_home_region=True, - ) + logger.info(f"Found {len(regions)} subscribed regions") + except Exception as error: + logger.warning( + f"Could not retrieve region subscriptions: {error}. Using configured region." + ) + # Fallback to configured region + config_region = self._session.config.get("region", "us-ashburn-1") + regions.append( + OCIRegion( + key=config_region, + name=OCI_REGIONS.get(config_region, config_region), + is_home_region=True, ) + ) return regions diff --git a/prowler/providers/oraclecloud/services/blockstorage/blockstorage_service.py b/prowler/providers/oraclecloud/services/blockstorage/blockstorage_service.py index ef704abc51..5b43f8d64b 100644 --- a/prowler/providers/oraclecloud/services/blockstorage/blockstorage_service.py +++ b/prowler/providers/oraclecloud/services/blockstorage/blockstorage_service.py @@ -35,10 +35,9 @@ class BlockStorage(OCIService): Returns: Block Storage client instance """ - client_region = self.regional_clients.get(region) - if client_region: - return self._create_oci_client(oci.core.BlockstorageClient) - return None + return self._create_oci_client( + oci.core.BlockstorageClient, config_overrides={"region": region} + ) def __list_volumes__(self, regional_client): """ @@ -112,7 +111,8 @@ class BlockStorage(OCIService): try: # Get availability domains for this compartment identity_client = self._create_oci_client( - oci.identity.IdentityClient + oci.identity.IdentityClient, + config_overrides={"region": regional_client.region}, ) availability_domains = identity_client.list_availability_domains( compartment_id=compartment.id diff --git a/prowler/providers/oraclecloud/services/compute/compute_instance_in_transit_encryption_enabled/compute_instance_in_transit_encryption_enabled.metadata.json b/prowler/providers/oraclecloud/services/compute/compute_instance_in_transit_encryption_enabled/compute_instance_in_transit_encryption_enabled.metadata.json index 75182b7d50..b5445197fb 100644 --- a/prowler/providers/oraclecloud/services/compute/compute_instance_in_transit_encryption_enabled/compute_instance_in_transit_encryption_enabled.metadata.json +++ b/prowler/providers/oraclecloud/services/compute/compute_instance_in_transit_encryption_enabled/compute_instance_in_transit_encryption_enabled.metadata.json @@ -1,35 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "compute_instance_in_transit_encryption_enabled", - "CheckTitle": "Ensure In-transit Encryption is enabled on Compute Instance", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Compute instance has in-transit encryption enabled", + "CheckType": [], "ServiceName": "compute", "SubServiceName": "", - "ResourceIdTemplate": "oci:compute:instance", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciComputeInstance", + "ResourceType": "Instance", "ResourceGroup": "compute", - "Description": "In-transit encryption protects data as it moves between the compute instance and block volumes. This is implemented through the Oracle Cloud Agent management plugin which enables encryption for block volume attachments.", - "Risk": "Without in-transit encryption, data moving between compute instances and block volumes could be intercepted or tampered with during transmission.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Block/Concepts/blockvolumeencryption.htm", + "Description": "**OCI compute instances** are evaluated for **in-transit encryption** on paravirtualized block or boot volume attachments, confirming that data exchanged between the instance and attached volumes is encrypted during transfer.", + "Risk": "Without **in-transit encryption**, volume traffic can be inspected or altered on internal paths. A threat actor or compromised host could read sensitive data, inject corrupted blocks, or replay writes, undermining **confidentiality** and **integrity**, and risking **availability** through data corruption.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Block/Concepts/blockvolumeencryption.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Compute/enable-encryption-in-transit.html" + ], "Remediation": { "Code": { - "CLI": "oci compute instance update --instance-id --agent-config '{\"isManagementDisabled\": false}'", + "CLI": "oci compute boot-volume-attachment update --boot-volume-attachment-id --is-pv-encryption-in-transit-enabled true", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Compute/enable-encryption-in-transit.html", - "Terraform": "resource \"oci_core_instance\" \"example\" {\n # ... other configuration ...\n agent_config {\n is_management_disabled = false\n }\n}" + "Other": "1. In the OCI Console, go to Compute > Instances and open the target instance\n2. Under Resources, click the boot volume attachment, choose Edit, enable \"Use in-transit encryption\", and Save\n3. For each attached block volume (paravirtualized), open the attachment (More actions > Edit), enable \"Use in-transit encryption\", and Save\n4. If Edit isn't available for an attachment, detach it and reattach it with \"Use in-transit encryption\" checked", + "Terraform": "```hcl\nresource \"oci_core_instance\" \"\" {\n # ... other required configuration ...\n launch_options {\n is_pv_encryption_in_transit_enabled = true # Critical: enables in-transit encryption for paravirtualized attachments\n }\n}\n```" }, "Recommendation": { - "Text": "Enable the Oracle Cloud Agent management plugin on all compute instances to enable in-transit encryption for block volume attachments.", - "Url": "https://hub.prowler.com/check/oci/compute_instance_in_transit_encryption_enabled" + "Text": "Enable **in-transit encryption** for all paravirtualized volume attachments and make it standard in golden images and IaC. Apply **defense in depth** with **customer-managed keys** and regular rotation. Periodically verify attachments and remediate drift so no instance communicates with volumes over unencrypted channels.", + "Url": "https://hub.prowler.com/check/compute_instance_in_transit_encryption_enabled" } }, "Categories": [ - "compute", "encryption" ], "DependsOn": [], diff --git a/prowler/providers/oraclecloud/services/compute/compute_instance_legacy_metadata_endpoint_disabled/compute_instance_legacy_metadata_endpoint_disabled.metadata.json b/prowler/providers/oraclecloud/services/compute/compute_instance_legacy_metadata_endpoint_disabled/compute_instance_legacy_metadata_endpoint_disabled.metadata.json index 29e2eb8c92..580296634e 100644 --- a/prowler/providers/oraclecloud/services/compute/compute_instance_legacy_metadata_endpoint_disabled/compute_instance_legacy_metadata_endpoint_disabled.metadata.json +++ b/prowler/providers/oraclecloud/services/compute/compute_instance_legacy_metadata_endpoint_disabled/compute_instance_legacy_metadata_endpoint_disabled.metadata.json @@ -1,36 +1,36 @@ { "Provider": "oraclecloud", "CheckID": "compute_instance_legacy_metadata_endpoint_disabled", - "CheckTitle": "Ensure Compute Instance Legacy Metadata service endpoint is disabled", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Compute instance legacy metadata service endpoint is disabled", + "CheckType": [], "ServiceName": "compute", "SubServiceName": "", - "ResourceIdTemplate": "oci:compute:instance", - "Severity": "medium", - "ResourceType": "OciComputeInstance", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Instance", "ResourceGroup": "compute", - "Description": "The legacy Instance Metadata Service (IMDS) v1 endpoints do not use session authentication. Disabling the legacy endpoints helps prevent unauthorized access to instance metadata.", - "Risk": "If legacy metadata endpoints are enabled, attackers who gain access to the instance may be able to access instance metadata without authentication, potentially exposing sensitive information.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/gettingmetadata.htm", + "Description": "**OCI compute instance metadata service** is configured so legacy **IMDS v1** endpoints are disabled, requiring session-authorized **IMDS v2** requests for metadata access", + "Risk": "Enabled **IMDS v1** permits unauthenticated metadata reads via local access or **SSRF**, compromising **confidentiality** of instance credentials, SSH keys, and custom data. Stolen tokens enable cloud API abuse, driving **privilege escalation**, **lateral movement**, and unauthorized changes that impact **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Compute/Tasks/gettingmetadata.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Compute/enforce-imds-v2.html" + ], "Remediation": { "Code": { "CLI": "oci compute instance update --instance-id --instance-options '{\"areLegacyImdsEndpointsDisabled\": true}'", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Compute/enforce-imds-v2.html", - "Terraform": "resource \"oci_core_instance\" \"example\" {\n # ... other configuration ...\n instance_options {\n are_legacy_imds_endpoints_disabled = true\n }\n}" + "Other": "1. In the OCI Console, go to Compute > Instances\n2. Select \n3. In Instance Details, next to Instance metadata service, click Edit\n4. Set Allowed IMDS version to Version 2 only\n5. Click Save changes", + "Terraform": "```hcl\nresource \"oci_core_instance\" \"\" {\n instance_options {\n are_legacy_imds_endpoints_disabled = true # Critical: disables IMDSv1, allowing only IMDSv2\n }\n}\n```" }, "Recommendation": { - "Text": "Disable legacy metadata service endpoints on all compute instances to enforce session-based authentication.", - "Url": "https://hub.prowler.com/check/oci/compute_instance_legacy_metadata_endpoint_disabled" + "Text": "Disable **IMDS v1** and require **IMDS v2** across all instances. Migrate applications to session-authorized requests and confirm image support. Enforce **least privilege** for instance principals, restrict untrusted processes from reaching `169.254.169.254`, and monitor metadata access to provide **defense in depth**.", + "Url": "https://hub.prowler.com/check/compute_instance_legacy_metadata_endpoint_disabled" } }, "Categories": [ - "compute", - "security-configuration" + "secrets", + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/compute/compute_instance_secure_boot_enabled/compute_instance_secure_boot_enabled.metadata.json b/prowler/providers/oraclecloud/services/compute/compute_instance_secure_boot_enabled/compute_instance_secure_boot_enabled.metadata.json index 68743d0089..15717ea036 100644 --- a/prowler/providers/oraclecloud/services/compute/compute_instance_secure_boot_enabled/compute_instance_secure_boot_enabled.metadata.json +++ b/prowler/providers/oraclecloud/services/compute/compute_instance_secure_boot_enabled/compute_instance_secure_boot_enabled.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "compute_instance_secure_boot_enabled", - "CheckTitle": "Ensure Secure Boot is enabled on Compute Instance", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Compute instance has Secure Boot enabled", + "CheckType": [], "ServiceName": "compute", "SubServiceName": "", - "ResourceIdTemplate": "oci:compute:instance", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciComputeInstance", + "ResourceType": "Instance", "ResourceGroup": "compute", - "Description": "Secure Boot helps ensure that the instance boots using only software that is trusted by the platform firmware. This prevents rootkits and bootkits from loading during the boot process.", - "Risk": "Without Secure Boot enabled, instances are vulnerable to boot-level malware that can compromise the entire system before the operating system loads.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Compute/References/shielded-instances.htm", + "Description": "**OCI compute instances** have **UEFI Secure Boot** enabled so the platform firmware loads only trusted, signed bootloaders, kernels, and drivers at startup.", + "Risk": "Without **Secure Boot**, unsigned or tampered boot code can run before the OS, enabling **bootkits/rootkits** to gain kernel-level persistence, bypass monitoring, alter logs, and exfiltrate secrets, eroding **integrity** and **confidentiality**, and risking **availability** through destructive changes or ransom staging.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Compute/enable-secure-boot.html", + "https://docs.oracle.com/en-us/iaas/Content/Compute/References/shielded-instances.htm" + ], "Remediation": { "Code": { - "CLI": "oci compute instance update --instance-id --platform-config '{\"isSecureBootEnabled\": true}'", + "CLI": "", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Compute/enable-secure-boot.html", - "Terraform": "resource \"oci_core_instance\" \"example\" {\n # ... other configuration ...\n platform_config {\n type = \"AMD_MILAN_BM\" # or appropriate platform\n is_secure_boot_enabled = true\n }\n}" + "Other": "1. In the OCI Console, go to Compute > Instances\n2. Click Create instance\n3. Choose an image and shape that support Shielded instance\n4. In the Security section, click Edit under Shielded instance and enable Secure Boot\n5. Click Create\n6. To remove the failing resource, select the original non-shielded instance > More actions > Terminate", + "Terraform": "```hcl\nresource \"oci_core_instance\" \"example\" {\n # ... other required configuration ...\n\n platform_config {\n type = \"AMD_VM\"\n is_secure_boot_enabled = true # Critical: enable Secure Boot to pass the check\n }\n}\n```" }, "Recommendation": { - "Text": "Enable Secure Boot on all compute instances to protect against boot-level malware and ensure system integrity.", - "Url": "https://hub.prowler.com/check/oci/compute_instance_secure_boot_enabled" + "Text": "Enable **Secure Boot** across instances and prefer **shielded instances**. Pair with **TPM** and **Measured Boot** where supported for defense in depth. Permit only trusted, signed kernels and drivers, and manage updates via hardened golden images and code signing. *On Windows VMs, use Secure Boot with TPM and Measured Boot.*", + "Url": "https://hub.prowler.com/check/compute_instance_secure_boot_enabled" } }, "Categories": [ - "compute", - "security-configuration" + "node-security" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/compute/compute_service.py b/prowler/providers/oraclecloud/services/compute/compute_service.py index 707fbc302e..72fc03dc5b 100644 --- a/prowler/providers/oraclecloud/services/compute/compute_service.py +++ b/prowler/providers/oraclecloud/services/compute/compute_service.py @@ -33,10 +33,9 @@ class Compute(OCIService): Returns: Compute client instance """ - client_region = self.regional_clients.get(region) - if client_region: - return self._create_oci_client(oci.core.ComputeClient) - return None + return self._create_oci_client( + oci.core.ComputeClient, config_overrides={"region": region} + ) def __list_instances__(self, regional_client): """ diff --git a/prowler/providers/oraclecloud/services/database/database_autonomous_database_access_restricted/database_autonomous_database_access_restricted.metadata.json b/prowler/providers/oraclecloud/services/database/database_autonomous_database_access_restricted/database_autonomous_database_access_restricted.metadata.json index 0531977bb2..07f8fbdbdd 100644 --- a/prowler/providers/oraclecloud/services/database/database_autonomous_database_access_restricted/database_autonomous_database_access_restricted.metadata.json +++ b/prowler/providers/oraclecloud/services/database/database_autonomous_database_access_restricted/database_autonomous_database_access_restricted.metadata.json @@ -1,35 +1,36 @@ { "Provider": "oraclecloud", "CheckID": "database_autonomous_database_access_restricted", - "CheckTitle": "Ensure Oracle Autonomous Shared Database (ADB) access is restricted or deployed within a VCN", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Autonomous Shared Database (ADB) is deployed within a VCN or restricts public access with whitelisted IPs excluding 0.0.0.0/0", + "CheckType": [], "ServiceName": "database", "SubServiceName": "", - "ResourceIdTemplate": "oci:database:autonomousdatabase", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AutonomousDatabase", "ResourceGroup": "database", - "Description": "Autonomous Shared Database instances should either have IP whitelisting configured or be deployed within a VCN to restrict network access and improve security posture.", - "Risk": "Public or unrestricted Autonomous Database access increases the attack surface and risk of unauthorized access.", - "RelatedUrl": "https://docs.oracle.com/en/cloud/paas/autonomous-database/adbsa/autonomous-private-endpoints.html", + "Description": "**OCI Autonomous Database (shared)** network exposure is evaluated: instances are treated as restricted when using a **VCN private endpoint** or when **ACLs** allow only specified IPs/VCNs. It identifies configurations with no ACL and no VCN, or ACLs permitting `0.0.0.0/0`.", + "Risk": "With **open access**, attackers can probe endpoints, brute-force credentials, or abuse leaked wallets to connect.\n\nImpact:\n- Confidentiality: unauthorized queries and data exfiltration\n- Integrity: malicious changes\n- Availability: heavy queries or scans causing service disruption", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en/cloud/paas/autonomous-database/adbsa/autonomous-private-endpoints.html", + "https://www.oracle.com/cloud/networking/private-endpoint/supported-services/" + ], "Remediation": { "Code": { - "CLI": "oci db autonomous-database create-private-endpoint --autonomous-database-id --subnet-id ", + "CLI": "oci db autonomous-database update --autonomous-database-id --is-access-control-enabled true --whitelisted-ips '[\"\"]'", "NativeIaC": "", - "Other": "1. Navigate to Autonomous Database\n2. Select the database instance\n3. Click 'More Actions' → 'Update'\n4. Under Network Access, select 'Private endpoint access only'\n5. Configure VCN and subnet for private endpoint\n6. Alternatively, configure Access Control List (ACL) with specific IP addresses", - "Terraform": "resource \"oci_database_autonomous_database\" \"adb\" {\n compartment_id = var.compartment_id\n db_name = \"MyADB\"\n display_name = \"My Autonomous Database\"\n is_free_tier = false\n db_workload = \"OLTP\"\n whitelisted_ips = [\"10.0.0.0/24\"]\n nsg_ids = [oci_core_network_security_group.adb_nsg.id]\n subnet_id = oci_core_subnet.private_subnet.id\n}" + "Other": "1. In OCI Console, go to Autonomous Database and select the instance\n2. Click More Actions > Update\n3. Under Network Access, enable Access control list (ACL)\n4. Add an allowed IP/CIDR (exclude 0.0.0.0/0) and remove any 0.0.0.0/0 entry\n5. Click Save", + "Terraform": "```hcl\nresource \"oci_database_autonomous_database\" \"\" {\n is_access_control_enabled = true # Critical: enable ACLs to restrict public access\n whitelisted_ips = [\"\"] # Critical: allow only specific IP/CIDR; do not use 0.0.0.0/0\n}\n```" }, "Recommendation": { - "Text": "Deploy Autonomous Databases within a VCN using private endpoints or configure strict IP whitelisting to restrict access.", - "Url": "https://hub.prowler.com/check/oci/database_autonomous_database_access_restricted" + "Text": "Prefer **VCN private endpoints** to eliminate internet exposure. If public access is required, enforce **least privilege** by limiting ACLs to specific CIDRs or VCNs; never use `0.0.0.0/0`.\n\nAdd **defense in depth** with NSGs and private connectivity (VPN/peering), monitor access, and rotate client wallets regularly.", + "Url": "https://hub.prowler.com/check/database_autonomous_database_access_restricted" } }, "Categories": [ - "network-security" + "internet-exposed", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_notification_topic_and_subscription_exists/events_notification_topic_and_subscription_exists.metadata.json b/prowler/providers/oraclecloud/services/events/events_notification_topic_and_subscription_exists/events_notification_topic_and_subscription_exists.metadata.json index 7da375bd9f..10fdbd25d1 100644 --- a/prowler/providers/oraclecloud/services/events/events_notification_topic_and_subscription_exists/events_notification_topic_and_subscription_exists.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_notification_topic_and_subscription_exists/events_notification_topic_and_subscription_exists.metadata.json @@ -1,36 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "events_notification_topic_and_subscription_exists", - "CheckTitle": "Create at least one notification topic and subscription to receive monitoring alerts", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Tenancy has at least one notification topic with active subscriptions", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "OnsTopic", "ResourceGroup": "messaging", - "Description": "At least one notification topic and subscription should exist to receive monitoring alerts.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Notifications** is evaluated for the existence of at least one **topic** that has one or more **subscriptions**.\n\nThe focus is on whether subscribed endpoints are present to receive Events and monitoring alerts.", + "Risk": "Without subscribed topics, alerts are not delivered, reducing **visibility** and delaying detection of malicious or accidental changes. This undermines **confidentiality** (undetected data access), **integrity** (unauthorized config changes), and **availability** (unresolved outages).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In the OCI Console, go to Menu > Application Integration > Notifications > Topics\n2. Click Create Topic, enter a name, and click Create\n3. Open the topic, click Create Subscription\n4. Select a protocol (e.g., Function), choose/provide the endpoint, and click Create\n5. Verify the subscription lifecycle state shows Active (confirm if prompted for protocols like Email)", + "Terraform": "```hcl\n# Create a notification topic\nresource \"oci_ons_notification_topic\" \"\" {\n compartment_id = var.compartment_ocid\n name = \"\" # Critical: creates the notification topic needed for the check\n}\n\n# Create a subscription on the topic (ensures topic has an active subscription)\nresource \"oci_ons_subscription\" \"\" {\n compartment_id = var.compartment_ocid\n topic_id = oci_ons_notification_topic..id # Critical: attaches the subscription to the topic\n protocol = \"ORACLE_FUNCTIONS\" # Critical: protocol that can become active without manual confirmation\n endpoint = \"\" # Critical: endpoint for the subscription\n}\n```" }, "Recommendation": { - "Text": "Create at least one notification topic and subscription to receive monitoring alerts", - "Url": "https://hub.prowler.com/check/oci/events_notification_topic_and_subscription_exists" + "Text": "Create a centralized **Notifications** topic with one or more **subscriptions**, and route critical Events/monitoring to it. Apply **least privilege** to topic management, use redundant channels, test delivery regularly, and tune filters to reduce noise. *Consider* escalation paths for `critical` alerts.", + "Url": "https://hub.prowler.com/check/events_notification_topic_and_subscription_exists" } }, "Categories": [ - "logging", - "monitoring" + "logging" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_cloudguard_problems/events_rule_cloudguard_problems.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_cloudguard_problems/events_rule_cloudguard_problems.metadata.json index 26bc01ddda..bf686d78ba 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_cloudguard_problems/events_rule_cloudguard_problems.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_cloudguard_problems/events_rule_cloudguard_problems.metadata.json @@ -1,35 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_cloudguard_problems", - "CheckTitle": "Ensure a notification is configured for Oracle Cloud Guard problems detected", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring Cloud Guard problems has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Ensure a notification is configured for Oracle Cloud Guard problems detected", - "Risk": "Without Cloud Guard, security threats may not be detected and remediated.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/cloud-guard/home.htm", + "Description": "**OCI Events rules** subscribe to **Cloud Guard problem lifecycle events**-`com.oraclecloud.cloudguard.problemdetected`, `com.oraclecloud.cloudguard.problemdismissed`, and `com.oraclecloud.cloudguard.problemremediated`-and include **notification actions**. *When Cloud Guard sets a reporting region, rules are expected in that region.*", + "Risk": "Without notifications for Cloud Guard problems, incidents can go unseen, delaying response. Ongoing issues can erode **confidentiality** via data exfiltration, threaten **integrity** through unremediated changes, and impact **availability** by allowing attacks to persist. Silent failures of automated remediation may also go unnoticed.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/cloud-guard/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci cloud-guard configuration update --compartment-id --status ENABLED --reporting-region ", + "CLI": "oci events rule create --compartment-id --display-name --is-enabled true --condition '{\"eventType\":[\"com.oraclecloud.cloudguard.problemdetected\",\"com.oraclecloud.cloudguard.problemdismissed\",\"com.oraclecloud.cloudguard.problemremediated\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"isEnabled\":true,\"topicId\":\"\"}]}' --region ", "NativeIaC": "", - "Other": "1. Navigate to Security > Cloud Guard\n2. Enable Cloud Guard\n3. Select reporting region\n4. Configure detectors and responders", - "Terraform": "resource \"oci_cloud_guard_cloud_guard_configuration\" \"example\" {\n compartment_id = var.tenancy_ocid\n reporting_region = var.region\n status = \"ENABLED\"\n}" + "Other": "1. In the OCI Console, go to Menu > Application Integration > Events Service > Rules\n2. Click Create Rule and select the Compartment; switch to the Cloud Guard reporting Region\n3. In Conditions, add event types: com.oraclecloud.cloudguard.problemdetected, com.oraclecloud.cloudguard.problemdismissed, com.oraclecloud.cloudguard.problemremediated\n4. Under Actions, add Notifications and select the desired Topic\n5. Ensure the rule is Enabled and click Create", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n is_enabled = true\n\n # critical: monitor Cloud Guard problem events\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.cloudguard.problemdetected\",\n \"com.oraclecloud.cloudguard.problemdismissed\",\n \"com.oraclecloud.cloudguard.problemremediated\"\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\"\n is_enabled = true\n topic_id = \"\" # critical: send notifications to this topic\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for Oracle Cloud Guard problems detected", - "Url": "https://hub.prowler.com/check/oci/cloudguard_notification_configured" + "Text": "Implement **event-driven alerts** for Cloud Guard problem lifecycle events and route them to trusted **notification channels** and your **SOC/SIEM**. Enforce **least privilege** on publish/subscribe, align rules with the **reporting region**, and use **severity-based filtering** to prioritize response within a **defense-in-depth** approach.", + "Url": "https://hub.prowler.com/check/events_rule_cloudguard_problems" } }, "Categories": [ - "monitoring" + "threat-detection" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_iam_group_changes/events_rule_iam_group_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_iam_group_changes/events_rule_iam_group_changes.metadata.json index 2052d76bb3..a4deb437ce 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_iam_group_changes/events_rule_iam_group_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_iam_group_changes/events_rule_iam_group_changes.metadata.json @@ -1,36 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_iam_group_changes", - "CheckTitle": "Ensure a notification is configured for IAM group changes", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring IAM group changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on IAM group changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** monitor **IAM group lifecycle events** (`creategroup`, `updategroup`, `deletegroup`) and include **notification actions** to generate alerts when these changes occur.", + "Risk": "Without alerts on **IAM group changes**, unauthorized privilege changes can persist unnoticed, enabling **privilege escalation** and broader access. This undermines **confidentiality** and **integrity**, and delays response to identity misuse.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --condition '{\"eventType\":[\"com.oraclecloud.identitycontrolplane.creategroup\",\"com.oraclecloud.identitycontrolplane.deletegroup\",\"com.oraclecloud.identitycontrolplane.updategroup\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In the OCI Console, go to Observability & Management > Events Service > Rules\n2. Click Create rule and set Name\n3. In Condition, select Event types and add:\n - com.oraclecloud.identitycontrolplane.creategroup\n - com.oraclecloud.identitycontrolplane.deletegroup\n - com.oraclecloud.identitycontrolplane.updategroup\n4. In Actions, add Notifications and select an existing Topic\n5. Click Create to save the rule", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n\n # Critical: Monitor IAM group changes\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.identitycontrolplane.creategroup\",\n \"com.oraclecloud.identitycontrolplane.deletegroup\",\n \"com.oraclecloud.identitycontrolplane.updategroup\"\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # Critical: Send notifications via OCI Notifications\n topic_id = \"\" # Topic OCID for notifications\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for IAM group changes", - "Url": "https://hub.prowler.com/check/oci/events_rule_iam_group_changes" + "Text": "Create **Events rules** for IAM group `create`, `update`, and `delete` and route them to **Notifications** channels consumed by the SOC. Enforce **least privilege** and **separation of duties** on rules/topics, forward events to a **SIEM**, and periodically test alert delivery.", + "Url": "https://hub.prowler.com/check/events_rule_iam_group_changes" } }, "Categories": [ - "logging", - "monitoring" + "threat-detection" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_iam_policy_changes/events_rule_iam_policy_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_iam_policy_changes/events_rule_iam_policy_changes.metadata.json index 2d605ba962..18a0e26c91 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_iam_policy_changes/events_rule_iam_policy_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_iam_policy_changes/events_rule_iam_policy_changes.metadata.json @@ -1,36 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_iam_policy_changes", - "CheckTitle": "Ensure a notification is configured for IAM policy changes", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring IAM policy changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on IAM policy changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** configured to capture **IAM policy create, update, and delete** events (`com.oraclecloud.identitycontrolplane.createpolicy`, `com.oraclecloud.identitycontrolplane.updatepolicy`, `com.oraclecloud.identitycontrolplane.deletepolicy`) and include a **notification action**.", + "Risk": "Without alerts on **IAM policy changes**, permissions can be altered unnoticed, enabling **privilege escalation**, unauthorized data access, and persistent footholds. Delayed visibility degrades **confidentiality** and **integrity** and slows incident response across compartments.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --condition '{\"eventType\":[\"com.oraclecloud.identitycontrolplane.createpolicy\",\"com.oraclecloud.identitycontrolplane.deletepolicy\",\"com.oraclecloud.identitycontrolplane.updatepolicy\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In the OCI Console, go to Observability & Management > Events Service\n2. Click Create Rule and set Display Name (leave Enabled)\n3. Under Conditions, choose Event Type, set Service Name to Identity and Access Management, and select:\n - com.oraclecloud.identitycontrolplane.createpolicy\n - com.oraclecloud.identitycontrolplane.deletepolicy\n - com.oraclecloud.identitycontrolplane.updatepolicy\n4. Under Actions, select Action Type: Notifications, then choose the target Topic\n5. Click Create", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = var.compartment_id\n display_name = \"\"\n is_enabled = true\n\n # Critical: monitor IAM policy create/delete/update events\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.identitycontrolplane.createpolicy\",\n \"com.oraclecloud.identitycontrolplane.deletepolicy\",\n \"com.oraclecloud.identitycontrolplane.updatepolicy\"\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # Critical: adds a Notifications action\n topic_id = var.topic_id # Critical: target Notifications topic\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for IAM policy changes", - "Url": "https://hub.prowler.com/check/oci/events_rule_iam_policy_changes" + "Text": "Create OCI Events rules for `...createpolicy`, `...updatepolicy`, and `...deletepolicy` with a **notification action** to trusted channels. Enforce **least privilege** on IAM and Events administration, require change approvals, and routinely test alerting to ensure rapid detection.", + "Url": "https://hub.prowler.com/check/events_rule_iam_policy_changes" } }, "Categories": [ - "logging", - "monitoring" + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_identity_provider_changes/events_rule_identity_provider_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_identity_provider_changes/events_rule_identity_provider_changes.metadata.json index 98b0d0c9de..2a80fb1a57 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_identity_provider_changes/events_rule_identity_provider_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_identity_provider_changes/events_rule_identity_provider_changes.metadata.json @@ -1,36 +1,36 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_identity_provider_changes", - "CheckTitle": "Ensure a notification is configured for Identity Provider changes", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule for identity provider changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on identity provider changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** monitor **IAM identity provider** creation, update, and deletion and include a **notification action**. The evaluation identifies rules that filter these events and route matching activity to a notification destination.", + "Risk": "Without alerts on **identity provider** changes, federation can be modified unnoticed, enabling unauthorized SSO, privilege escalation, or account takeover. Delayed visibility degrades incident response and threatens **confidentiality** and **integrity** of tenant access.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/Events/Reference/eventsproducers.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --condition '{\"eventType\":[\"com.oraclecloud.identitycontrolplane.createidentityprovider\",\"com.oraclecloud.identitycontrolplane.deleteidentityprovider\",\"com.oraclecloud.identitycontrolplane.updateidentityprovider\"]}' --is-enabled true --actions '{\"actions\":[{\"actionType\":\"ONS\",\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In the OCI Console, go to Observability & Management > Events Service > Rules and click Create rule\n2. Set Name and select the target Compartment\n3. In Rule condition, add event types: CreateIdentityProvider, DeleteIdentityProvider, UpdateIdentityProvider (service: Identity)\n4. In Actions, add Notification, select your Topic\n5. Ensure Enabled is On and click Create", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n is_enabled = true\n\n # Critical: monitor identity provider create/delete/update events\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.identitycontrolplane.createidentityprovider\",\n \"com.oraclecloud.identitycontrolplane.deleteidentityprovider\",\n \"com.oraclecloud.identitycontrolplane.updateidentityprovider\",\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # Critical: send notifications via OCI Notifications\n topic_id = \"\" # Topic to notify\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for Identity Provider changes", - "Url": "https://hub.prowler.com/check/oci/events_rule_identity_provider_changes" + "Text": "Configure rules to capture **identity provider** `create`, `update`, and `delete` events and send notifications to responders and SIEM. Enforce **least privilege** on IdP management, require approvals for changes, and test alert paths. Use **defense in depth** with audit logging to spot anomalous identity changes.", + "Url": "https://hub.prowler.com/check/events_rule_identity_provider_changes" } }, "Categories": [ - "logging", - "monitoring" + "identity-access", + "logging" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes.metadata.json index a6a3e9e8ee..7626df3a43 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_idp_group_mapping_changes", - "CheckTitle": "Ensure a notification is configured for IdP group mapping changes", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule for IdP group mapping changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on IdP group mapping changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** monitor **IdP group mapping changes** with **notification actions** for `com.oraclecloud.identitycontrolplane.addidpgroupmapping`, `com.oraclecloud.identitycontrolplane.removeidpgroupmapping`, and `com.oraclecloud.identitycontrolplane.updateidpgroupmapping`.", + "Risk": "Without **alerts** on IdP group mapping changes, federated users can gain unauthorized group memberships unnoticed, enabling **privilege escalation** and broader access to OCI resources. This undermines **confidentiality** and **integrity**, and may affect **availability** through misuse of elevated permissions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/Events/Reference/eventsproducers.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --is-enabled true --condition '{\"eventType\":[\"com.oraclecloud.identitycontrolplane.addidpgroupmapping\",\"com.oraclecloud.identitycontrolplane.removeidpgroupmapping\",\"com.oraclecloud.identitycontrolplane.updateidpgroupmapping\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"isEnabled\":true,\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In OCI Console, go to Observability & Management > Events Service > Rules\n2. Click Create rule\n3. Condition: add Event types:\n - com.oraclecloud.identitycontrolplane.addidpgroupmapping\n - com.oraclecloud.identitycontrolplane.removeidpgroupmapping\n - com.oraclecloud.identitycontrolplane.updateidpgroupmapping\n4. Actions: Add action > Notifications (ONS) and select the target Topic\n5. Ensure Rule is Enabled and click Create", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n is_enabled = true\n\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.identitycontrolplane.addidpgroupmapping\", # critical: monitor IdP group mapping add\n \"com.oraclecloud.identitycontrolplane.removeidpgroupmapping\", # critical: monitor IdP group mapping remove\n \"com.oraclecloud.identitycontrolplane.updateidpgroupmapping\" # critical: monitor IdP group mapping update\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # critical: adds notification action\n topic_id = \"\" # critical: ONS topic to notify\n is_enabled = true\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for IdP group mapping changes", - "Url": "https://hub.prowler.com/check/oci/events_rule_idp_group_mapping_changes" + "Text": "Define **Events rules** for IdP group mapping changes (`com.oraclecloud.identitycontrolplane.addidpgroupmapping`, `...removeidpgroupmapping`, `...updateidpgroupmapping`) and route notifications to monitored channels via **OCI Notifications**. Apply **least privilege** and **separation of duties**, and integrate alerts with a SIEM for **defense in depth**.", + "Url": "https://hub.prowler.com/check/events_rule_idp_group_mapping_changes" } }, "Categories": [ - "logging", - "monitoring" + "identity-access" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes.py b/prowler/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes.py index 184640d0bc..cf503a54f4 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes.py +++ b/prowler/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes.py @@ -15,17 +15,33 @@ class events_rule_idp_group_mapping_changes(Check): """Execute the events_rule_idp_group_mapping_changes check.""" findings = [] - # Required event types for IdP group mapping changes - required_event_types = [ + # OCI CIS 3.1 renamed create/delete to add/remove. Accept both to keep + # compatibility with rules that still use the legacy event names. + current_required_event_types = [ + "com.oraclecloud.identitycontrolplane.addidpgroupmapping", + "com.oraclecloud.identitycontrolplane.removeidpgroupmapping", + "com.oraclecloud.identitycontrolplane.updateidpgroupmapping", + ] + legacy_required_event_types = [ "com.oraclecloud.identitycontrolplane.createidpgroupmapping", "com.oraclecloud.identitycontrolplane.deleteidpgroupmapping", "com.oraclecloud.identitycontrolplane.updateidpgroupmapping", ] # Filter rules that monitor IdP group mapping changes - matching_rules = filter_rules_by_event_types( - events_client.rules, required_event_types - ) + matching_rules = [] + seen_rule_ids = set() + for required_event_types in ( + current_required_event_types, + legacy_required_event_types, + ): + for rule, condition in filter_rules_by_event_types( + events_client.rules, required_event_types + ): + rule_id = getattr(rule, "id", None) or id(rule) + if rule_id not in seen_rule_ids: + matching_rules.append((rule, condition)) + seen_rule_ids.add(rule_id) # Create findings for each matching rule for rule, _ in matching_rules: diff --git a/prowler/providers/oraclecloud/services/events/events_rule_local_user_authentication/events_rule_local_user_authentication.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_local_user_authentication/events_rule_local_user_authentication.metadata.json index 8243102781..4b9d4b3e3b 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_local_user_authentication/events_rule_local_user_authentication.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_local_user_authentication/events_rule_local_user_authentication.metadata.json @@ -1,37 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_local_user_authentication", - "CheckTitle": "Ensure a notification is configured for Local OCI User Authentication", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring local OCI user authentication has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciEventRule", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Ensure that an Event Rule and Notification are configured to detect when a user authenticates via OCI local authentication. Event Rules are compartment-scoped and will detect events in child compartments. This Event rule is required to be created at the root compartment level.", - "Risk": "Without proper notification for local user authentication events, unauthorized access attempts or suspicious authentication activity may go undetected, increasing the risk of security breaches.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** targeting `com.oraclecloud.identitysignon.interactivelogin` are assessed for configured **notification actions** to monitor local user interactive sign-ins. Rules are compartment-scoped and can cover child compartments.", + "Risk": "Without alerts on local sign-ins, **account takeovers** and **brute-force** attempts can go unnoticed. Attackers with local access can exfiltrate data (confidentiality), change configurations (integrity), and disrupt services (availability), delaying detection and response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name user-authentication-rule --is-enabled true --condition '{\"eventType\":[\"com.oraclecloud.identitysignon.interactivelogin\"]}' --compartment-id --actions '{\"actions\":[{\"actionType\":\"ONS\",\"isEnabled\":true,\"topicId\":\"\"}]}'", + "CLI": "oci events rule create --display-name --is-enabled true --condition '{\"eventType\":[\"com.oraclecloud.identitysignon.interactivelogin\"]}' --compartment-id --actions '{\"actions\":[{\"actionType\":\"ONS\",\"isEnabled\":true,\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Events/detect-oci-local-authentication.html", - "Terraform": "resource \"oci_events_rule\" \"user_auth_rule\" {\n display_name = \"user-authentication-events\"\n is_enabled = true\n compartment_id = var.tenancy_ocid\n condition = \"{\\\"eventType\\\":[\\\"com.oraclecloud.identitysignon.interactivelogin\\\"]}\"\n actions {\n actions {\n action_type = \"ONS\"\n is_enabled = true\n topic_id = oci_ons_notification_topic.topic.id\n }\n }\n}" + "Other": "1. In the OCI Console, go to Application Integration > Events Service > Rules\n2. Click Create rule\n3. Set Name and select the target Compartment\n4. In Condition, set Event Type to: com.oraclecloud.identitysignon.interactivelogin\n5. Add Action: choose Notifications, select the Topic, and ensure it is Enabled\n6. Click Create", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"example\" {\n display_name = \"\"\n is_enabled = true\n compartment_id = \"\"\n\n # Critical: filter for local OCI user authentication events\n condition = jsonencode({ eventType = [\"com.oraclecloud.identitysignon.interactivelogin\"] })\n\n actions {\n actions {\n # Critical: ONS notification action to a topic\n action_type = \"ONS\"\n is_enabled = true\n topic_id = \"\"\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Create an Event Rule with notifications configured to monitor local OCI user authentication events (com.oraclecloud.identitysignon.interactivelogin)", - "Url": "https://hub.prowler.com/check/oci/events_rule_local_user_authentication" + "Text": "Create an Events rule for `com.oraclecloud.identitysignon.interactivelogin` with **notification actions** delivering real-time alerts to monitored channels or workflows. Integrate with a SIEM, tune filters to reduce noise, and apply **least privilege** and **defense in depth** to limit local account exposure.", + "Url": "https://hub.prowler.com/check/events_rule_local_user_authentication" } }, "Categories": [ - "logging", - "monitoring", - "security-configuration" + "threat-detection" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_network_gateway_changes/events_rule_network_gateway_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_network_gateway_changes/events_rule_network_gateway_changes.metadata.json index 8ecd56572b..8a0e89c7cd 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_network_gateway_changes/events_rule_network_gateway_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_network_gateway_changes/events_rule_network_gateway_changes.metadata.json @@ -1,36 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_network_gateway_changes", - "CheckTitle": "Ensure a notification is configured for changes to network gateways", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring network gateway changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on network gateway changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** monitor **network gateway** lifecycle and attachment changes (DRG, Internet, NAT, Service, and Local Peering gateways) and include **notification actions** so changes generate alerts.\n\nThe evaluation looks for rules filtered to these events and confirms they trigger notifications.", + "Risk": "Unalerted gateway changes can reroute traffic, expose services, or sever connectivity.\n\nAttackers or misconfigurations may modify routes or attachments to enable data exfiltration, man-in-the-middle, or denial of service, degrading **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --condition '{\"eventType\":[\"com.oraclecloud.virtualnetwork.createdrg\",\"com.oraclecloud.virtualnetwork.deletedrg\",\"com.oraclecloud.virtualnetwork.updatedrg\",\"com.oraclecloud.virtualnetwork.createdrgattachment\",\"com.oraclecloud.virtualnetwork.deletedrgattachment\",\"com.oraclecloud.virtualnetwork.updatedrgattachment\",\"com.oraclecloud.virtualnetwork.changeinternetgatewaycompartment\",\"com.oraclecloud.virtualnetwork.createinternetgateway\",\"com.oraclecloud.virtualnetwork.deleteinternetgateway\",\"com.oraclecloud.virtualnetwork.updateinternetgateway\",\"com.oraclecloud.virtualnetwork.changelocalpeeringgatewaycompartment\",\"com.oraclecloud.virtualnetwork.createlocalpeeringgateway\",\"com.oraclecloud.virtualnetwork.deletelocalpeeringgateway.end\",\"com.oraclecloud.virtualnetwork.updatelocalpeeringgateway\",\"com.oraclecloud.natgateway.changenatgatewaycompartment\",\"com.oraclecloud.natgateway.createnatgateway\",\"com.oraclecloud.natgateway.deletenatgateway\",\"com.oraclecloud.natgateway.updatenatgateway\",\"com.oraclecloud.servicegateway.attachserviceid\",\"com.oraclecloud.servicegateway.changeservicegatewaycompartment\",\"com.oraclecloud.servicegateway.createservicegateway\",\"com.oraclecloud.servicegateway.deleteservicegateway.end\",\"com.oraclecloud.servicegateway.detachserviceid\",\"com.oraclecloud.servicegateway.updateservicegateway\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In OCI Console, go to Observability & Management > Events Service > Rules\n2. Click Create rule and select the target Compartment\n3. Set Display name\n4. Under Matching events, add these Event types:\n - com.oraclecloud.virtualnetwork.createdrg\n - com.oraclecloud.virtualnetwork.deletedrg\n - com.oraclecloud.virtualnetwork.updatedrg\n - com.oraclecloud.virtualnetwork.createdrgattachment\n - com.oraclecloud.virtualnetwork.deletedrgattachment\n - com.oraclecloud.virtualnetwork.updatedrgattachment\n - com.oraclecloud.virtualnetwork.changeinternetgatewaycompartment\n - com.oraclecloud.virtualnetwork.createinternetgateway\n - com.oraclecloud.virtualnetwork.deleteinternetgateway\n - com.oraclecloud.virtualnetwork.updateinternetgateway\n - com.oraclecloud.virtualnetwork.changelocalpeeringgatewaycompartment\n - com.oraclecloud.virtualnetwork.createlocalpeeringgateway\n - com.oraclecloud.virtualnetwork.deletelocalpeeringgateway.end\n - com.oraclecloud.virtualnetwork.updatelocalpeeringgateway\n - com.oraclecloud.natgateway.changenatgatewaycompartment\n - com.oraclecloud.natgateway.createnatgateway\n - com.oraclecloud.natgateway.deletenatgateway\n - com.oraclecloud.natgateway.updatenatgateway\n - com.oraclecloud.servicegateway.attachserviceid\n - com.oraclecloud.servicegateway.changeservicegatewaycompartment\n - com.oraclecloud.servicegateway.createservicegateway\n - com.oraclecloud.servicegateway.deleteservicegateway.end\n - com.oraclecloud.servicegateway.detachserviceid\n - com.oraclecloud.servicegateway.updateservicegateway\n5. Add action: Notification, select the target Notifications (ONS) topic\n6. Click Create (ensure the rule is enabled)", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n\n # Critical: Monitor required network gateway event types\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.virtualnetwork.createdrg\",\n \"com.oraclecloud.virtualnetwork.deletedrg\",\n \"com.oraclecloud.virtualnetwork.updatedrg\",\n \"com.oraclecloud.virtualnetwork.createdrgattachment\",\n \"com.oraclecloud.virtualnetwork.deletedrgattachment\",\n \"com.oraclecloud.virtualnetwork.updatedrgattachment\",\n \"com.oraclecloud.virtualnetwork.changeinternetgatewaycompartment\",\n \"com.oraclecloud.virtualnetwork.createinternetgateway\",\n \"com.oraclecloud.virtualnetwork.deleteinternetgateway\",\n \"com.oraclecloud.virtualnetwork.updateinternetgateway\",\n \"com.oraclecloud.virtualnetwork.changelocalpeeringgatewaycompartment\",\n \"com.oraclecloud.virtualnetwork.createlocalpeeringgateway\",\n \"com.oraclecloud.virtualnetwork.deletelocalpeeringgateway.end\",\n \"com.oraclecloud.virtualnetwork.updatelocalpeeringgateway\",\n \"com.oraclecloud.natgateway.changenatgatewaycompartment\",\n \"com.oraclecloud.natgateway.createnatgateway\",\n \"com.oraclecloud.natgateway.deletenatgateway\",\n \"com.oraclecloud.natgateway.updatenatgateway\",\n \"com.oraclecloud.servicegateway.attachserviceid\",\n \"com.oraclecloud.servicegateway.changeservicegatewaycompartment\",\n \"com.oraclecloud.servicegateway.createservicegateway\",\n \"com.oraclecloud.servicegateway.deleteservicegateway.end\",\n \"com.oraclecloud.servicegateway.detachserviceid\",\n \"com.oraclecloud.servicegateway.updateservicegateway\"\n ]\n })\n\n actions {\n actions {\n # Critical: Add notification action so the rule passes the check\n action_type = \"ONS\" # Sends to Notifications service (ONS)\n topic_id = \"\" # ONS topic OCID\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for changes to network gateways", - "Url": "https://hub.prowler.com/check/oci/events_rule_network_gateway_changes" + "Text": "Define **event rules** that match `create`, `update`, `delete`, `attach`, and `detach` actions for all gateway types and send **notifications** to monitored channels.\n\nApply **least privilege** on topics, cover all compartments/regions, integrate with SIEM, and use **defense in depth** with network change approvals.", + "Url": "https://hub.prowler.com/check/events_rule_network_gateway_changes" } }, "Categories": [ - "logging", - "monitoring" + "logging" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_network_security_group_changes/events_rule_network_security_group_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_network_security_group_changes/events_rule_network_security_group_changes.metadata.json index 69eec0a679..f8ce61ada9 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_network_security_group_changes/events_rule_network_security_group_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_network_security_group_changes/events_rule_network_security_group_changes.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_network_security_group_changes", - "CheckTitle": "Ensure a notification is configured for network security group changes", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring network security group changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on network security group changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** targeting **Network Security Group (NSG)** changes are evaluated for **notification actions**. Monitored events: `createnetworksecuritygroup`, `updatenetworksecuritygroup`, `deletenetworksecuritygroup`, and `changenetworksecuritygroupcompartment` under `com.oraclecloud.virtualnetwork`.", + "Risk": "Absent notifications for NSG changes enable silent policy drift.\n- **Confidentiality**: permissive edits can expose services and drive data exfiltration.\n- **Integrity**: attackers can reroute traffic or bypass micro-segmentation.\n- **Availability**: deletions/misconfigurations may isolate workloads or widen DDoS exposure.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/Events/Reference/eventsproducers.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --condition '{\"eventType\":[\"com.oraclecloud.virtualnetwork.changenetworksecuritygroupcompartment\",\"com.oraclecloud.virtualnetwork.createnetworksecuritygroup\",\"com.oraclecloud.virtualnetwork.deletenetworksecuritygroup\",\"com.oraclecloud.virtualnetwork.updatenetworksecuritygroup\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In the OCI Console, go to Observability & Management > Events Service > Rules\n2. Click Create rule\n3. Set Display name and Compartment\n4. Under Conditions, add Event types:\n - com.oraclecloud.virtualnetwork.changenetworksecuritygroupcompartment\n - com.oraclecloud.virtualnetwork.createnetworksecuritygroup\n - com.oraclecloud.virtualnetwork.deletenetworksecuritygroup\n - com.oraclecloud.virtualnetwork.updatenetworksecuritygroup\n5. Under Actions, click Add action > Notifications, select Publish to topic, choose the ONS topic\n6. Click Create to save the rule", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"example\" {\n compartment_id = var.compartment_id\n display_name = \"\"\n\n # Critical: Monitor NSG change events to satisfy the check\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.virtualnetwork.changenetworksecuritygroupcompartment\", # Required NSG event\n \"com.oraclecloud.virtualnetwork.createnetworksecuritygroup\", # Required NSG event\n \"com.oraclecloud.virtualnetwork.deletenetworksecuritygroup\", # Required NSG event\n \"com.oraclecloud.virtualnetwork.updatenetworksecuritygroup\" # Required NSG event\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # Critical: Notification action required\n topic_id = var.topic_id # Sends notifications to this topic\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for network security group changes", - "Url": "https://hub.prowler.com/check/oci/events_rule_network_security_group_changes" + "Text": "Implement **Events** rules for NSG lifecycle changes with **notification actions** to a monitored topic/SIEM.\n- Include `createnetworksecuritygroup`, `updatenetworksecuritygroup`, `deletenetworksecuritygroup`, `changenetworksecuritygroupcompartment`\n- Enforce **least privilege** and **separation of duties** on NSG and Events admins\n- Regularly test and tune alerts", + "Url": "https://hub.prowler.com/check/events_rule_network_security_group_changes" } }, "Categories": [ - "logging", - "monitoring" + "logging" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_route_table_changes/events_rule_route_table_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_route_table_changes/events_rule_route_table_changes.metadata.json index 498a592a0b..c1ba4f7b8e 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_route_table_changes/events_rule_route_table_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_route_table_changes/events_rule_route_table_changes.metadata.json @@ -1,36 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_route_table_changes", - "CheckTitle": "Ensure a notification is configured for changes to route tables", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule for route table changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on route table changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** for **VCN route tables** monitor lifecycle and compartment changes and include **notification actions**.\n\nThe evaluation looks for rules that capture `create`, `update`, `delete`, and `changeCompartment` events for route tables and send notifications.", + "Risk": "Without notifications on route table changes, **routing tampering** can persist unnoticed:\n- Exposure of private subnets to the Internet\n- Traffic hijack or blackholing\n- Segmentation bypass enabling lateral movement\nThis threatens **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --condition '{\"eventType\":[\"com.oraclecloud.virtualnetwork.changeroutetablecompartment\",\"com.oraclecloud.virtualnetwork.createroutetable\",\"com.oraclecloud.virtualnetwork.deleteroutetable\",\"com.oraclecloud.virtualnetwork.updateroutetable\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In OCI Console, go to Observability & Management > Events Service > Rules.\n2. Click Create rule and select the target Compartment; set a Display name.\n3. In Condition, add event types:\n - com.oraclecloud.virtualnetwork.changeroutetablecompartment\n - com.oraclecloud.virtualnetwork.createroutetable\n - com.oraclecloud.virtualnetwork.deleteroutetable\n - com.oraclecloud.virtualnetwork.updateroutetable\n4. In Actions, add Notifications (ONS) and select the desired Topic.\n5. Ensure the rule is Enabled and click Create.", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.virtualnetwork.changeroutetablecompartment\", # critical: monitor route table change events\n \"com.oraclecloud.virtualnetwork.createroutetable\",\n \"com.oraclecloud.virtualnetwork.deleteroutetable\",\n \"com.oraclecloud.virtualnetwork.updateroutetable\"\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # critical: adds notification action\n topic_id = \"\" # critical: ONS topic to receive notifications\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for changes to route tables", - "Url": "https://hub.prowler.com/check/oci/events_rule_route_table_changes" + "Text": "Create an **Events rule** that captures route table `create`, `update`, `delete`, and `changeCompartment` events and routes them to **notifications** used by on-call and SIEM. Enforce **least privilege** on route edits, require **change approvals**, and apply **defense in depth** with auditing and automated response.", + "Url": "https://hub.prowler.com/check/events_rule_route_table_changes" } }, "Categories": [ - "logging", - "monitoring" + "logging" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_security_list_changes/events_rule_security_list_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_security_list_changes/events_rule_security_list_changes.metadata.json index 243695eedf..7d1ace4d24 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_security_list_changes/events_rule_security_list_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_security_list_changes/events_rule_security_list_changes.metadata.json @@ -1,36 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_security_list_changes", - "CheckTitle": "Ensure a notification is configured for security list changes", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring security list changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on security list changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** for VCN **security lists** monitor lifecycle changes-create, update, delete, and compartment moves-and include **notification actions**. The evaluation looks for rules that filter these events and confirms a configured notification target.", + "Risk": "Without timely alerts on security list changes, **unauthorized rule edits** can expose subnets to the Internet, enabling scans, brute force, and lateral movement (**confidentiality**), permit traffic manipulation (**integrity**), or block ports causing outages (**availability**). Delayed detection widens blast radius.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --condition '{\"eventType\":[\"com.oraclecloud.virtualnetwork.changesecuritylistcompartment\",\"com.oraclecloud.virtualnetwork.createsecuritylist\",\"com.oraclecloud.virtualnetwork.deletesecuritylist\",\"com.oraclecloud.virtualnetwork.updatesecuritylist\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"topicId\":\"\",\"isEnabled\":true}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In OCI Console, go to Observability & Management > Events Service > Rules\n2. Click Create rule, set a name and compartment\n3. In Condition, set Event Type to include exactly:\n - com.oraclecloud.virtualnetwork.changesecuritylistcompartment\n - com.oraclecloud.virtualnetwork.createsecuritylist\n - com.oraclecloud.virtualnetwork.deletesecuritylist\n - com.oraclecloud.virtualnetwork.updatesecuritylist\n4. Click Add action, choose Notifications, select an existing topic\n5. Ensure the action is enabled and click Create", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n is_enabled = true\n\n # Critical: monitor security list change event types required by the check\n # This ensures the rule matches the specific VCN Security List events\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.virtualnetwork.changesecuritylistcompartment\",\n \"com.oraclecloud.virtualnetwork.createsecuritylist\",\n \"com.oraclecloud.virtualnetwork.deletesecuritylist\",\n \"com.oraclecloud.virtualnetwork.updatesecuritylist\"\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # Critical: add notification action\n topic_id = \"\" # ONS topic OCID to send notifications\n is_enabled = true # Ensure notifications are sent\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for security list changes", - "Url": "https://hub.prowler.com/check/oci/events_rule_security_list_changes" + "Text": "Define **Events** rules for security list create/update/delete and route them to **Notifications** or automated responders. Enforce **least privilege** for network and Events admins, apply **change control** with logging, cover critical compartments/regions, and periodically test alerts for reliability.", + "Url": "https://hub.prowler.com/check/events_rule_security_list_changes" } }, "Categories": [ - "logging", - "monitoring" + "logging" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_user_changes/events_rule_user_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_user_changes/events_rule_user_changes.metadata.json index 03d7ce365b..88985cec93 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_user_changes/events_rule_user_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_user_changes/events_rule_user_changes.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_user_changes", - "CheckTitle": "Ensure a notification is configured for user changes", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring user changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", - "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on user changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** targeting **IAM user changes** (e.g., `com.oraclecloud.identitycontrolplane.createuser` and related update/delete/state events) are assessed for attached **notification actions**.\n\nThe finding indicates which rules listen for these events and whether they are configured to emit alerts.", + "Risk": "Absent alerts on user lifecycle events, **unauthorized account creation**, **privilege escalation**, or **re-enabling disabled users** may go undetected. This delays containment, enabling persistence and lateral movement that erode **confidentiality** and **integrity**, and introduces audit gaps reducing **accountability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --condition '{\"eventType\":[\"com.oraclecloud.identitycontrolplane.createuser\",\"com.oraclecloud.identitycontrolplane.deleteuser\",\"com.oraclecloud.identitycontrolplane.updateuser\",\"com.oraclecloud.identitycontrolplane.updateusercapabilities\",\"com.oraclecloud.identitycontrolplane.updateuserstate\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In the OCI Console, go to Observability & Management > Events Service > Rules and click Create Rule\n2. Select the Compartment and enter a Name\n3. Under Rule Conditions, set Event Type to include:\n - com.oraclecloud.identitycontrolplane.createuser\n - com.oraclecloud.identitycontrolplane.deleteuser\n - com.oraclecloud.identitycontrolplane.updateuser\n - com.oraclecloud.identitycontrolplane.updateusercapabilities\n - com.oraclecloud.identitycontrolplane.updateuserstate\n4. Under Actions, add a Notification action and select the desired Notifications (ONS) Topic\n5. Click Create", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n\n # Critical: monitor IAM user change event types\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.identitycontrolplane.createuser\",\n \"com.oraclecloud.identitycontrolplane.deleteuser\",\n \"com.oraclecloud.identitycontrolplane.updateuser\",\n \"com.oraclecloud.identitycontrolplane.updateusercapabilities\",\n \"com.oraclecloud.identitycontrolplane.updateuserstate\",\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # Critical: notification action type\n topic_id = \"\" # Critical: ONS topic to notify\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for user changes", - "Url": "https://hub.prowler.com/check/oci/events_rule_user_changes" + "Text": "Create and maintain **Events rules** for IAM user lifecycle changes and attach reliable **notification actions** to security-owned channels (SIEM, paging, email, chat).\n\nEnforce **least privilege** and **separation of duties** on rule management, and use **defense in depth** by correlating alerts with audit logs and automating containment.", + "Url": "https://hub.prowler.com/check/events_rule_user_changes" } }, "Categories": [ - "logging", - "monitoring" + "identity-access", + "logging" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/events/events_rule_vcn_changes/events_rule_vcn_changes.metadata.json b/prowler/providers/oraclecloud/services/events/events_rule_vcn_changes/events_rule_vcn_changes.metadata.json index 1aa3fe2211..2aab16177a 100644 --- a/prowler/providers/oraclecloud/services/events/events_rule_vcn_changes/events_rule_vcn_changes.metadata.json +++ b/prowler/providers/oraclecloud/services/events/events_rule_vcn_changes/events_rule_vcn_changes.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "events_rule_vcn_changes", - "CheckTitle": "Ensure a notification is configured for VCN changes", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Event rule monitoring VCN changes has notification actions configured", + "CheckType": [], "ServiceName": "events", "SubServiceName": "", - "ResourceIdTemplate": "oci:events:rule", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciEventsRule", + "ResourceType": "EventRule", "ResourceGroup": "messaging", - "Description": "Event rules should be configured to notify on VCN changes.", - "Risk": "Without proper event monitoring, security-relevant changes may go unnoticed.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "Description": "**OCI Events rules** exist to capture **VCN lifecycle changes** (`create`, `update`, `delete`) via event types `com.oraclecloud.virtualnetwork.createvcn`, `com.oraclecloud.virtualnetwork.updatevcn`, `com.oraclecloud.virtualnetwork.deletevcn`, and include **notification actions**.", + "Risk": "Missing alerts for **VCN changes** reduces visibility of network perimeter modifications.\n\nAttackers or mistakes can silently open Internet access, alter routes for **data exfiltration**, or delete gateways/subnets, harming **confidentiality** and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Events/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/Events/Reference/eventsproducers.htm" + ], "Remediation": { "Code": { - "CLI": "oci events rule create --display-name --condition --actions ", + "CLI": "oci events rule create --compartment-id --display-name --is-enabled true --condition '{\"eventType\":[\"com.oraclecloud.virtualnetwork.createvcn\",\"com.oraclecloud.virtualnetwork.deletevcn\",\"com.oraclecloud.virtualnetwork.updatevcn\"]}' --actions '{\"actions\":[{\"actionType\":\"ONS\",\"isEnabled\":true,\"topicId\":\"\"}]}'", "NativeIaC": "", - "Other": "1. Navigate to Observability & Management > Events Service\n2. Create a new rule\n3. Configure the event condition\n4. Add notification action\n5. Save the rule", - "Terraform": "resource \"oci_events_rule\" \"example\" {\n display_name = \"rule\"\n is_enabled = true\n condition = jsonencode({\n eventType = [\"com.oraclecloud.*\"]\n })\n actions {\n actions {\n action_type = \"ONS\"\n topic_id = var.topic_id\n }\n }\n}" + "Other": "1. In OCI Console, go to Observability & Management > Events Service > Rules\n2. Click Create rule and enter a name; set Rule state to Enabled\n3. Under Conditions, add Event types: com.oraclecloud.virtualnetwork.createvcn, com.oraclecloud.virtualnetwork.deletevcn, com.oraclecloud.virtualnetwork.updatevcn\n4. Under Actions, add Notification and select the target Notifications topic\n5. Click Create to save the rule", + "Terraform": "```hcl\nresource \"oci_events_rule\" \"\" {\n compartment_id = var.compartment_id\n display_name = \"\"\n is_enabled = true\n\n # critical: monitor VCN create/delete/update events\n condition = jsonencode({\n eventType = [\n \"com.oraclecloud.virtualnetwork.createvcn\",\n \"com.oraclecloud.virtualnetwork.deletevcn\",\n \"com.oraclecloud.virtualnetwork.updatevcn\"\n ]\n })\n\n actions {\n actions {\n action_type = \"ONS\" # critical: send notifications via OCI Notifications\n topic_id = var.topic_id # critical: target Notifications topic\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure a notification is configured for VCN changes", - "Url": "https://hub.prowler.com/check/oci/events_rule_vcn_changes" + "Text": "Create and enable **Events rules** for VCN lifecycle changes (**create**, **update**, **delete**) with **notification actions** to monitored channels.\n\nApply **least privilege** to manage rules and notifications, integrate alerts with incident response, and periodically test to support **defense in depth**.", + "Url": "https://hub.prowler.com/check/events_rule_vcn_changes" } }, "Categories": [ - "logging", - "monitoring" + "logging" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/filestorage/filestorage_file_system_encrypted_with_cmk/filestorage_file_system_encrypted_with_cmk.metadata.json b/prowler/providers/oraclecloud/services/filestorage/filestorage_file_system_encrypted_with_cmk/filestorage_file_system_encrypted_with_cmk.metadata.json index f85ca82554..75545fb401 100644 --- a/prowler/providers/oraclecloud/services/filestorage/filestorage_file_system_encrypted_with_cmk/filestorage_file_system_encrypted_with_cmk.metadata.json +++ b/prowler/providers/oraclecloud/services/filestorage/filestorage_file_system_encrypted_with_cmk/filestorage_file_system_encrypted_with_cmk.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "filestorage_file_system_encrypted_with_cmk", - "CheckTitle": "Ensure File Storage Systems are encrypted with Customer Managed Keys", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "File Storage file system is encrypted with a customer-managed KMS key", + "CheckType": [], "ServiceName": "filestorage", "SubServiceName": "", - "ResourceIdTemplate": "oci:filestorage:resource", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciFilestorageResource", + "ResourceType": "FileSystem", "ResourceGroup": "storage", - "Description": "File systems should be encrypted with Customer Managed Keys (CMK) for enhanced security and control over encryption keys.", - "Risk": "Not meeting this requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/", + "Description": "**OCI File Storage** file systems use **Customer-Managed Keys** (`CMEK`) for encryption when a KMS key is associated, instead of the default Oracle-managed encryption.", + "Risk": "Using provider-managed keys limits control over key lifecycle and access, weakening **confidentiality**. You cannot enforce custom rotation, revoke use, or apply granular key permissions, increasing exposure to insider misuse, legal compulsion, or compromised services. It may hinder **compliance** and complicate incident response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-FileStorage/file-storage-systems-encrypted-with-cmks.html", + "https://docs.oracle.com/en-us/iaas/Content/File/Tasks/encrypt-file-system.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci fs file-system update --file-system-id --kms-key-id ", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-FileStorage/file-storage-systems-encrypted-with-cmks.html", - "Terraform": "" + "Other": "1. Sign in to the OCI Console\n2. Go to Storage > File Storage > File Systems and select the target file system\n3. In the Encryption section, click Edit (or Change key)\n4. Select Customer-managed key, choose the Vault and KMS key\n5. Click Save to apply", + "Terraform": "```hcl\nresource \"oci_file_storage_file_system\" \"\" {\n availability_domain = \"\"\n compartment_id = \"\"\n kms_key_id = \"\" # Critical: associates a customer-managed KMS key to encrypt the file system\n}\n```" }, "Recommendation": { - "Text": "Ensure File Storage Systems are encrypted with Customer Managed Keys", - "Url": "https://hub.prowler.com/check/oci/filestorage_file_system_encrypted_with_cmk" + "Text": "Encrypt file systems with **Customer-Managed Keys** in OCI KMS. Apply **least privilege** on key usage, enable periodic rotation, and require dual control for key administration. Monitor key activity with centralized logging. Use **defense in depth** by combining `CMEK` with network isolation and strong access governance.", + "Url": "https://hub.prowler.com/check/filestorage_file_system_encrypted_with_cmk" } }, "Categories": [ - "security-configuration" + "encryption" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/filestorage/filestorage_service.py b/prowler/providers/oraclecloud/services/filestorage/filestorage_service.py index 108d021ea8..edbfabf395 100644 --- a/prowler/providers/oraclecloud/services/filestorage/filestorage_service.py +++ b/prowler/providers/oraclecloud/services/filestorage/filestorage_service.py @@ -20,10 +20,9 @@ class Filestorage(OCIService): def __get_client__(self, region): """Get the Filestorage client for a region.""" - client_region = self.regional_clients.get(region) - if client_region: - return self._create_oci_client(oci.file_storage.FileStorageClient) - return None + return self._create_oci_client( + oci.file_storage.FileStorageClient, config_overrides={"region": region} + ) def __list_file_systems__(self, regional_client): """List all file_systems.""" @@ -40,7 +39,8 @@ class Filestorage(OCIService): try: # Get availability domains for this compartment identity_client = self._create_oci_client( - oci.identity.IdentityClient + oci.identity.IdentityClient, + config_overrides={"region": regional_client.region}, ) availability_domains = identity_client.list_availability_domains( compartment_id=compartment.id diff --git a/prowler/providers/oraclecloud/services/identity/identity_iam_admins_cannot_update_tenancy_admins/identity_iam_admins_cannot_update_tenancy_admins.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_iam_admins_cannot_update_tenancy_admins/identity_iam_admins_cannot_update_tenancy_admins.metadata.json index 1213d3a723..e96ba8346a 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_iam_admins_cannot_update_tenancy_admins/identity_iam_admins_cannot_update_tenancy_admins.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_iam_admins_cannot_update_tenancy_admins/identity_iam_admins_cannot_update_tenancy_admins.metadata.json @@ -1,31 +1,30 @@ { "Provider": "oraclecloud", "CheckID": "identity_iam_admins_cannot_update_tenancy_admins", - "CheckTitle": "Ensure IAM administrators cannot update tenancy Administrators group", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "All IAM policies granting manage/use on groups or users in the tenancy restrict access to the Administrators group", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", - "Severity": "high", - "ResourceType": "OciIamUser", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "Policy", "ResourceGroup": "IAM", - "Description": "IAM administrators should not be able to update the tenancy Administrators group.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI IAM policies** granting **manage/use** on **groups or users** in the tenancy are evaluated for a condition that excludes the **Administrators** group, such as `target.group.name != 'Administrators'`.\n\nPolicies missing this restriction are identified.", + "Risk": "Ability to modify the **Administrators** group enables **privilege escalation** to full tenancy control. This threatens confidentiality (broad data access), integrity (policy/user changes), and availability (resource deletion). Attackers could add persistence accounts, disable safeguards, and evade oversight.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci iam policy update --policy-id --statements \"[\\\"Allow group to manage groups, users in tenancy where target.group.name != 'Administrators'\\\"]\"", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/protect-administrators-group-with-access-policies.html", - "Terraform": "" + "Other": "1. In OCI Console, go to Identity & Security > Policies\n2. Open any policy that allows a group to manage or use groups/users in the tenancy\n3. Click Edit policy and update each affected statement to append exactly:\n where target.group.name != 'Administrators'\n4. Save changes\n5. Repeat for all such policies", + "Terraform": "```hcl\nresource \"oci_identity_policy\" \"\" {\n name = \"\"\n compartment_id = \"\"\n description = \"\"\n statements = [\n # Critical: Protect the Administrators group from modification by this policy\n # Adds a where clause so the policy can't target the Administrators group\n \"Allow group to manage groups, users in tenancy where target.group.name != 'Administrators'\"\n ]\n}\n```" }, "Recommendation": { - "Text": "Ensure IAM administrators cannot update tenancy Administrators group", - "Url": "https://hub.prowler.com/check/oci/identity_iam_admins_cannot_update_tenancy_admins" + "Text": "Apply **least privilege**: avoid tenancy-wide manage/use on groups or users. When delegation is required, include a condition excluding the **Administrators** group (e.g., `target.group.name != 'Administrators'`). Enforce **segregation of duties**, require approvals for group changes, and monitor/audit identity policy activity.", + "Url": "https://hub.prowler.com/check/identity_iam_admins_cannot_update_tenancy_admins" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_instance_principal_used/identity_instance_principal_used.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_instance_principal_used/identity_instance_principal_used.metadata.json index 6b09417a93..376d790c20 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_instance_principal_used/identity_instance_principal_used.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_instance_principal_used/identity_instance_principal_used.metadata.json @@ -1,31 +1,30 @@ { "Provider": "oraclecloud", "CheckID": "identity_instance_principal_used", - "CheckTitle": "Ensure Instance Principal authentication is used for OCI instances, OCI Cloud Databases and OCI Functions to access OCI resources", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Instance principal authentication is configured for OCI instances, OCI Cloud Databases, and OCI Functions", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciIamUser", + "ResourceType": "DynamicResourceGroup", "ResourceGroup": "IAM", - "Description": "Instance Principal authentication should be used instead of user credentials.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI dynamic groups** configured for **instance principal** access to workloads like **Compute instances**, **Functions**, and **Autonomous Databases**. The evaluation looks for matching rules that target these resources (e.g., `instance`, `fnfunc`, `autonomousdatabase`) to confirm workload identity usage.", + "Risk": "Using user credentials or long-lived keys instead of **instance principals** exposes secrets and weakens **least privilege** and **accountability**. Stolen keys from code or pipelines enable unauthorized API calls, data exfiltration, and privilege abuse, impacting **confidentiality** and **integrity** of OCI resources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci iam dynamic-group create --compartment-id --name --matching-rule \"ALL {resource.type = 'instance'}\"", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In the OCI Console, go to Identity & Security > Dynamic Groups\n2. Click Create Dynamic Group\n3. Enter Name: \n4. Set Matching Rule to: ALL {resource.type = 'instance'}\n5. Click Create", + "Terraform": "```hcl\nresource \"oci_identity_dynamic_group\" \"dg\" {\n compartment_id = \"\"\n name = \"\"\n description = \"Enable instance principals\"\n matching_rule = \"ALL {resource.type = 'instance'}\" # Critical: creates a Dynamic Group for instances to enable instance principal auth\n}\n```" }, "Recommendation": { - "Text": "Ensure Instance Principal authentication is used for OCI instances, OCI Cloud Databases and OCI Functions to access OCI resources", - "Url": "https://hub.prowler.com/check/oci/identity_instance_principal_used" + "Text": "Adopt **workload identities** with **instance principals** and granular dynamic groups. Apply **least privilege** policies to those groups, avoid long-lived user keys, and remove embedded credentials from code and CI/CD. Monitor access patterns and favor short-lived, auditable tokens as part of a **zero trust** approach.", + "Url": "https://hub.prowler.com/check/identity_instance_principal_used" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_no_resources_in_root_compartment/identity_no_resources_in_root_compartment.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_no_resources_in_root_compartment/identity_no_resources_in_root_compartment.metadata.json index e9469246a1..b75eff003e 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_no_resources_in_root_compartment/identity_no_resources_in_root_compartment.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_no_resources_in_root_compartment/identity_no_resources_in_root_compartment.metadata.json @@ -1,30 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "identity_no_resources_in_root_compartment", - "CheckTitle": "Ensure no resources are created in the root compartment", + "CheckTitle": "No resources exist in the root compartment", "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:tenancy", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciTenancy", + "ResourceType": "Compartment", "ResourceGroup": "governance", - "Description": "The root compartment is the top-level compartment in your tenancy and should be used only for management purposes. All other cloud resources should be created in child compartments to maintain proper organization, access control, and resource isolation.", - "Risk": "Creating resources in the root compartment bypasses the benefits of compartmentalization, makes access control management difficult, violates the principle of least privilege, and increases the risk of unauthorized access to resources. It also makes it harder to implement effective IAM policies and resource governance.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcompartments.htm", + "Description": "**OCI root compartment** is evaluated for the presence of **user resources**. The finding highlights any assets created at the tenancy root and provides a count per `resource_type`.", + "Risk": "**Resources in the root compartment** inherit broad tenancy-level policies, weakening **least privilege**. Compromise or error can enable wide **unauthorized access**, **lateral movement**, and destructive changes, degrading **confidentiality** and **integrity**, while complicating audits and quota governance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcompartments.htm" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/check-for-root-compartment-resources.html", - "Terraform": "" + "Other": "1. In the OCI Console, go to Governance & Administration > Tenancy Explorer\n2. Set Compartment to the root compartment and enable \"Show resources in subcompartments\" off\n3. Note each resource listed in the root compartment\n4. For each resource: navigate to its service page (e.g., Compute > Instances), select the resource, click Actions (three dots) > Move resource\n5. Choose a non-root compartment and confirm\n6. If a resource type cannot be moved, delete it and recreate it in a non-root compartment\n7. Repeat until Tenancy Explorer shows 0 resources in the root compartment", + "Terraform": "```hcl\n# Place resources in a non-root compartment to avoid creating them in the root\nresource \"oci_core_vcn\" \"\" {\n compartment_id = \"\" # CRITICAL: ensure this is a non-root compartment OCID to pass the check\n cidr_block = \"10.0.0.0/16\"\n display_name = \"\"\n}\n```" }, "Recommendation": { - "Text": "Move all resources from the root compartment to appropriate child compartments. From OCI Console: 1. Identify resources in the root compartment. 2. Create or select appropriate child compartments. 3. Move resources to child compartments using the 'Move Resource' option available for most resource types. 4. Update any policies or automation that reference root compartment resources.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcompartments.htm" + "Text": "Reserve the **root compartment** for governance only.\n- Create workload-specific child compartments\n- Scope IAM to them per **least privilege** and **separation of duties**\n- Enforce boundaries with quotas and tags\n- Update automation to avoid root placement\n- Regularly audit and relocate stray resources", + "Url": "https://hub.prowler.com/check/identity_no_resources_in_root_compartment" } }, "Categories": [ + "trust-boundaries", "identity-access" ], "DependsOn": [], diff --git a/prowler/providers/oraclecloud/services/identity/identity_non_root_compartment_exists/identity_non_root_compartment_exists.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_non_root_compartment_exists/identity_non_root_compartment_exists.metadata.json index f8d1807d67..814c8ddf9e 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_non_root_compartment_exists/identity_non_root_compartment_exists.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_non_root_compartment_exists/identity_non_root_compartment_exists.metadata.json @@ -1,31 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "identity_non_root_compartment_exists", - "CheckTitle": "Create at least one non-root compartment in your tenancy to store cloud resources", + "CheckTitle": "Tenancy has at least one active non-root compartment", "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:tenancy", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciTenancy", + "ResourceType": "Compartment", "ResourceGroup": "governance", - "Description": "Compartments are used to organize and isolate your cloud resources. Creating at least one compartment is a fundamental best practice for organizing resources in your tenancy. The root compartment should not be used directly for resource creation.", - "Risk": "Without proper compartmentalization, resource management becomes difficult, access control is harder to implement, and it violates the principle of least privilege. Using only the root compartment makes it impossible to implement proper resource isolation and access controls.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcompartments.htm", + "Description": "**OCI tenancy** includes at least one **active non-root compartment**. Only compartments below the `root` level are considered, indicating that resources are organized outside the root scope.", + "Risk": "Using only the `root` compartment removes segmentation and forces broad tenancy-wide access. This endangers **confidentiality** and **integrity** through cross-project visibility, mis-scoped policies, and accidental changes with global impact, and can affect **availability** via bulk deletions or misconfigurations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcompartments.htm" + ], "Remediation": { "Code": { "CLI": "oci iam compartment create --compartment-id --name --description ''", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/create-non-root-compartment.html", - "Terraform": "" + "Other": "1. In the OCI Console, go to Identity & Security > Compartments\n2. Click Create Compartment\n3. Enter Name: and a Description\n4. Ensure the Parent Compartment is the tenancy (root) or desired non-root parent\n5. Click Create", + "Terraform": "```hcl\n# Creates a non-root compartment under the tenancy to pass the check\nresource \"oci_identity_compartment\" \"\" {\n compartment_id = \"\" # Critical: parent OCID (tenancy) for non-root compartment\n name = \"\" # Critical: compartment name\n description = \"compartment\" # Critical: required to create\n}\n```" }, "Recommendation": { - "Text": "Create at least one compartment to organize your cloud resources. From OCI Console: 1. Navigate to Identity & Security -> Compartments. 2. Click 'Create Compartment'. 3. Enter a name and description. 4. Select the parent compartment (typically the root). 5. Click 'Create Compartment'.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcompartments.htm" + "Text": "Create dedicated **non-root compartments** per workload, environment, or team; avoid placing resources in the `root` compartment.\n\nApply granular policies at the compartment level to enforce **least privilege** and **separation of duties**. Use a clear hierarchy and tags, and review the design regularly as the tenancy evolves.", + "Url": "https://hub.prowler.com/check/identity_non_root_compartment_exists" } }, "Categories": [ - "identity-access" + "identity-access", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/identity/identity_password_policy_expires_within_365_days/identity_password_policy_expires_within_365_days.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_password_policy_expires_within_365_days/identity_password_policy_expires_within_365_days.metadata.json index fad9007c30..809975308e 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_password_policy_expires_within_365_days/identity_password_policy_expires_within_365_days.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_password_policy_expires_within_365_days/identity_password_policy_expires_within_365_days.metadata.json @@ -1,31 +1,31 @@ { "Provider": "oraclecloud", "CheckID": "identity_password_policy_expires_within_365_days", - "CheckTitle": "Ensure IAM password policy expires passwords within 365 days", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Identity Domain password policy expires passwords within 365 days", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciIamUser", + "ResourceType": "Policy", "ResourceGroup": "IAM", - "Description": "Password policy should expire passwords within 365 days.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI Identity Domain password policies** are evaluated to confirm **password expiration** is configured and set to `<= 365` days (`password_expires_after`). System-managed immutable policies (`SimplePasswordPolicy`, `StandardPasswordPolicy`) are excluded.\n\n*Legacy IAM lacks password expiration; tenancies without Identity Domains require manual assessment.*", + "Risk": "Missing or >`365`-day **password expiration** extends the window for **credential stuffing**, **brute force**, and use of leaked passwords. This enables unauthorized access, data exposure, and configuration changes, harming **confidentiality** and **integrity**, and allowing attacker persistence after staff turnover.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "https://docs.oracle.com/en-us/iaas/tools/terraform-provider-oci/7.16.0/docs/r/identity_domains_password_policy.html" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Sign in to the OCI Console\n2. Go to Identity & Security > Domains and select your identity domain\n3. In the domain, open Security (or Domain settings) > Password policies\n4. Edit the relevant password policy\n5. Set \"Password expires after (days)\" to 365 or less\n6. Click Save", + "Terraform": "```hcl\nresource \"oci_identity_domains_password_policy\" \"\" {\n idcs_endpoint = \"\"\n name = \"\"\n schemas = [\"urn:ietf:params:scim:schemas:oracle:idcs:PasswordPolicy\"]\n\n password_expires_after = 365 # Critical: ensures passwords expire within 365 days to pass the check\n}\n```" }, "Recommendation": { - "Text": "Ensure IAM password policy expires passwords within 365 days", - "Url": "https://hub.prowler.com/check/oci/identity_password_policy_expires_within_365_days" + "Text": "Enforce **password rotation** at `<= 365` days in Identity Domains. Combine with **MFA**, strong composition rules, history, and minimum password age to prevent reuse. Apply **least privilege**, disable dormant accounts, and regularly review credential policies. *If on legacy IAM, adopt Identity Domains to gain expiration controls.*", + "Url": "https://hub.prowler.com/check/identity_password_policy_expires_within_365_days" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_password_policy_minimum_length_14/identity_password_policy_minimum_length_14.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_password_policy_minimum_length_14/identity_password_policy_minimum_length_14.metadata.json index 579b9b2ce5..b8daacaa10 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_password_policy_minimum_length_14/identity_password_policy_minimum_length_14.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_password_policy_minimum_length_14/identity_password_policy_minimum_length_14.metadata.json @@ -1,31 +1,30 @@ { "Provider": "oraclecloud", "CheckID": "identity_password_policy_minimum_length_14", - "CheckTitle": "Ensure IAM password policy requires minimum length of 14 or greater", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "IAM password policy requires passwords to be at least 14 characters long", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:tenancy", - "Severity": "medium", - "ResourceType": "OciIamPasswordPolicy", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Policy", "ResourceGroup": "IAM", - "Description": "Ensure IAM password policy requires minimum length of 14 or greater. Password policies are used to enforce password complexity requirements. IAM password policies can be used to ensure password are at least a certain length. It is recommended that the password policy require a minimum password length 14.", - "Risk": "Setting a password complexity policy increases account resiliency against brute force login attempts.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm", + "Description": "**OCI IAM password policies** are evaluated to confirm a **minimum password length** of `>= 14` characters is enforced. The assessment considers policies defined in **Identity Domains** and the legacy tenancy policy, and also detects when no password policy exists. System-managed immutable policies (`SimplePasswordPolicy`, `StandardPasswordPolicy`) are excluded.", + "Risk": "Short or missing password requirements weaken authentication, enabling **brute-force**, **password spraying**, and faster **offline cracking**. Compromised accounts can enable unauthorized console/API use, leading to **data exfiltration** (C), **unauthorized changes** (I), and service disruption via destructive actions (A).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm" + ], "Remediation": { "Code": { - "CLI": "oci iam authentication-policy update --compartment-id --password-policy '{\"minimumPasswordLength\": 14}'", + "CLI": "oci iam authentication-policy update --compartment-id --password-policy '{\"minimumPasswordLength\":14}'", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/require-14-characters-password-policy.html", - "Terraform": "" + "Other": "1. In the OCI Console, go to Identity & Security > Domains\n2. Select > Security > Password policy\n3. Set Minimum length to 14 and click Save\n4. If you do not use Identity Domains: go to Identity & Security > Authentication settings, edit Password policy, set Minimum password length to 14, and Save", + "Terraform": "```hcl\nresource \"oci_identity_authentication_policy\" \"\" {\n compartment_id = \"\"\n\n password_policy {\n minimum_password_length = 14 # Critical: enforces minimum password length of 14\n }\n}\n```" }, "Recommendation": { - "Text": "Make sure IAM password policy requires a minimum password length of 14 or more characters.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm" + "Text": "Enforce a **minimum password length** of `>= 14`, preferably using passphrases. Combine with **MFA**, complexity and reuse limits, lockout/throttling, and routine policy reviews. Apply **least privilege** to limit blast radius and monitor authentication events. *If using external IdPs, require equivalent policies there.*", + "Url": "https://hub.prowler.com/check/identity_password_policy_minimum_length_14" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_password_policy_prevents_reuse/identity_password_policy_prevents_reuse.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_password_policy_prevents_reuse/identity_password_policy_prevents_reuse.metadata.json index 73b5417b9f..15d06140ec 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_password_policy_prevents_reuse/identity_password_policy_prevents_reuse.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_password_policy_prevents_reuse/identity_password_policy_prevents_reuse.metadata.json @@ -1,31 +1,30 @@ { "Provider": "oraclecloud", "CheckID": "identity_password_policy_prevents_reuse", - "CheckTitle": "Ensure IAM password policy prevents password reuse", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Identity Domain password policy prevents password reuse by remembering at least 24 previous passwords", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciIamUser", + "ResourceType": "User", "ResourceGroup": "IAM", - "Description": "Password policy should prevent password reuse.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI Identity Domains** password policies are evaluated for **password reuse prevention** via **password history** (`num_passwords_in_history >= 24`). System-managed policies (`SimplePasswordPolicy`, `StandardPasswordPolicy`) are excluded. *Legacy IAM lacks password history.*", + "Risk": "Without **password history**, users can reuse old passwords. Compromised credentials remain valid after resets, enabling account takeover, unauthorized changes, and **lateral movement**, degrading **confidentiality** and **integrity** and weakening recovery from password-related incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to the OCI Console\n2. Go to Identity & Security > Domains and select \n3. Open Security > Password policies\n4. Edit the affected policy (e.g., Default Password Policy)\n5. Set \"Number of passwords in history\" (Remember previous passwords) to 24\n6. Click Save\n7. Repeat for any other password policies in the domain", "Terraform": "" }, "Recommendation": { - "Text": "Ensure IAM password policy prevents password reuse", - "Url": "https://hub.prowler.com/check/oci/identity_password_policy_prevents_reuse" + "Text": "Enforce **password history** in Identity Domains with `num_passwords_in_history >= 24`. Pair with `min_password_age` to prevent rapid cycling, and maintain strong complexity and expiration rules. *If using legacy IAM, migrate to Identity Domains.* Complement with **MFA** for defense in depth.", + "Url": "https://hub.prowler.com/check/identity_password_policy_prevents_reuse" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_service.py b/prowler/providers/oraclecloud/services/identity/identity_service.py index b6d488966c..a0932bd54f 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_service.py +++ b/prowler/providers/oraclecloud/services/identity/identity_service.py @@ -35,7 +35,7 @@ class Identity(OCIService): self.__threading_call__(self.__list_dynamic_groups__) self.__threading_call__(self.__list_domains__) self.__threading_call__(self.__list_domain_password_policies__) - self.__get_password_policy__() + self.__threading_call__(self.__get_password_policy__) self.__threading_call__(self.__search_root_compartment_resources__) self.__threading_call__(self.__search_active_non_root_compartments__) @@ -49,10 +49,9 @@ class Identity(OCIService): Returns: Identity client instance """ - client_region = self.regional_clients.get(region) - if client_region: - return self._create_oci_client(oci.identity.IdentityClient) - return None + return self._create_oci_client( + oci.identity.IdentityClient, config_overrides={"region": region} + ) def __list_users__(self, regional_client): """ @@ -66,7 +65,7 @@ class Identity(OCIService): if regional_client.region not in self.provider.identity.region: return - identity_client = self._create_oci_client(oci.identity.IdentityClient) + identity_client = self.__get_client__(regional_client.region) logger.info("Identity - Listing Users...") @@ -316,7 +315,7 @@ class Identity(OCIService): if regional_client.region not in self.provider.identity.region: return - identity_client = self._create_oci_client(oci.identity.IdentityClient) + identity_client = self.__get_client__(regional_client.region) logger.info("Identity - Listing Groups...") @@ -359,7 +358,7 @@ class Identity(OCIService): if regional_client.region not in self.provider.identity.region: return - identity_client = self._create_oci_client(oci.identity.IdentityClient) + identity_client = self.__get_client__(regional_client.region) logger.info("Identity - Listing Policies...") @@ -404,7 +403,7 @@ class Identity(OCIService): if regional_client.region not in self.provider.identity.region: return - identity_client = self._create_oci_client(oci.identity.IdentityClient) + identity_client = self.__get_client__(regional_client.region) logger.info("Identity - Listing Dynamic Groups...") @@ -452,7 +451,7 @@ class Identity(OCIService): if regional_client.region not in self.provider.identity.region: return - identity_client = self._create_oci_client(oci.identity.IdentityClient) + identity_client = self.__get_client__(regional_client.region) logger.info("Identity - Listing Identity Domains...") @@ -518,6 +517,14 @@ class Identity(OCIService): policies_response = domain_client.list_password_policies() for policy in policies_response.data.resources: + # Skip system-managed immutable policies that are + # hidden in the OCI Console and not user-configurable + if policy.id in ( + "SimplePasswordPolicy", + "StandardPasswordPolicy", + ): + continue + domain.password_policies.append( DomainPasswordPolicy( id=policy.id, @@ -541,10 +548,13 @@ class Identity(OCIService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - def __get_password_policy__(self): + def __get_password_policy__(self, regional_client): """Get the password policy for the tenancy.""" try: - identity_client = self._create_oci_client(oci.identity.IdentityClient) + if regional_client.region not in self.provider.identity.region: + return + + identity_client = self.__get_client__(regional_client.region) logger.info("Identity - Getting Password Policy...") @@ -576,7 +586,8 @@ class Identity(OCIService): # Create search client using the helper method for proper authentication search_client = self._create_oci_client( - oci.resource_search.ResourceSearchClient + oci.resource_search.ResourceSearchClient, + config_overrides={"region": regional_client.region}, ) # Query to search for resources in root compartment @@ -623,7 +634,8 @@ class Identity(OCIService): # Create search client using the helper method for proper authentication search_client = self._create_oci_client( - oci.resource_search.ResourceSearchClient + oci.resource_search.ResourceSearchClient, + config_overrides={"region": regional_client.region}, ) # Query to search for active compartments in the tenancy (excluding root) diff --git a/prowler/providers/oraclecloud/services/identity/identity_service_level_admins_exist/identity_service_level_admins_exist.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_service_level_admins_exist/identity_service_level_admins_exist.metadata.json index 2689417666..b56d44ebfc 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_service_level_admins_exist/identity_service_level_admins_exist.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_service_level_admins_exist/identity_service_level_admins_exist.metadata.json @@ -1,27 +1,31 @@ { "Provider": "oraclecloud", "CheckID": "identity_service_level_admins_exist", - "CheckTitle": "Ensure service level admins are created to manage resources of particular service", + "CheckTitle": "Identity policy does not grant broad 'manage all-resources' permissions", "CheckType": [], "ServiceName": "identity", "SubServiceName": "", "ResourceIdTemplate": "", - "Severity": "medium", - "ResourceType": "OciIdentityPolicy", + "Severity": "high", + "ResourceType": "Policy", "ResourceGroup": "IAM", - "Description": "To apply least-privilege security principle, create service-level administrators in corresponding groups and assign specific users to each service-level administrative group in a tenancy. This limits administrative access to specific services.", - "Risk": "Without service-level administrators, there is a risk of excessive permissions being granted, violating the principle of least privilege.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Concepts/policygetstarted.htm", + "Description": "**OCI IAM policies** are reviewed for **overly broad entitlements**, specifically statements granting `manage all-resources` without scoping to particular services or compartments. Only **active policies** are considered; the default tenant admin policy is excluded.", + "Risk": "Broad `manage all-resources` grants erode **least privilege**, enabling access across the tenancy. A compromised user or misused token could cause **privilege escalation**, **data exfiltration**, destructive changes, and service disruption, impacting confidentiality, integrity, and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/Concepts/policygetstarted.htm", + "https://docs.oracle.com/en-us/iaas/Content/Identity/getstarted/identity-domains.htm" + ], "Remediation": { "Code": { - "CLI": "oci iam policy create --compartment-id --name --description '' --statements '[\"Allow group to manage -family in compartment \"]'", + "CLI": "oci iam policy update --policy-id --statements '[\"Allow group to manage instance-family in compartment \"]'", "NativeIaC": "", - "Other": "1. Navigate to Identity → Policies\n2. Click 'Create Policy'\n3. Create policies granting service-level admin permissions to specific groups in specific compartments\n4. Example: 'Allow group VolumeAdmins to manage volume-family in compartment Production'", - "Terraform": "resource \"oci_identity_policy\" \"service_admin_policy\" {\n compartment_id = var.compartment_id\n name = \"ServiceLevelAdminPolicy\"\n description = \"Service-level admin policy\"\n statements = [\n \"Allow group VolumeAdmins to manage volume-family in compartment Production\"\n ]\n}" + "Other": "1. In OCI Console, go to Identity & Security > Policies\n2. Open the policy that contains a statement with \"manage all-resources\"\n3. Click Edit policy statements\n4. Remove the statement(s) containing \"manage all-resources\"\n5. Add a service-specific statement, e.g.: Allow group to manage instance-family in compartment \n6. Click Save changes", + "Terraform": "```hcl\nresource \"oci_identity_policy\" \"\" {\n compartment_id = \"\"\n name = \"\"\n description = \"\"\n\n # Critical: restrict to a specific service family to avoid broad 'manage all-resources'\n statements = [\n \"Allow group to manage instance-family in compartment \"\n ]\n}\n```" }, "Recommendation": { - "Text": "Create service-level administrators with limited permissions to specific services within compartments.", - "Url": "https://docs.prowler.com/checks/oci/oci-iam-policies/identity_service_level_admins_exist" + "Text": "Apply **least privilege** and **separation of duties**:\n- Replace `manage all-resources` with service-scoped permissions and compartment limits\n- Create service-level admin groups for specific families\n- Use tags/regions for conditions, *when applicable*\n- Employ time-bound elevation and periodic reviews", + "Url": "https://hub.prowler.com/check/identity_service_level_admins_exist" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_tenancy_admin_permissions_limited/identity_tenancy_admin_permissions_limited.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_tenancy_admin_permissions_limited/identity_tenancy_admin_permissions_limited.metadata.json index 2021a3bd71..4503b18141 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_tenancy_admin_permissions_limited/identity_tenancy_admin_permissions_limited.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_tenancy_admin_permissions_limited/identity_tenancy_admin_permissions_limited.metadata.json @@ -1,31 +1,30 @@ { "Provider": "oraclecloud", "CheckID": "identity_tenancy_admin_permissions_limited", - "CheckTitle": "Ensure permissions on all resources are given only to the tenancy administrator group", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "OCI IAM policy does not grant 'manage all-resources in tenancy' unless it is the Tenant Admin Policy", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", - "Severity": "high", - "ResourceType": "OciIamUser", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "Policy", "ResourceGroup": "IAM", - "Description": "Only the tenancy administrator group should have permissions to manage all resources in the tenancy.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI IAM policies** are analyzed for statements granting `manage all-resources in tenancy` to groups. Only the `Tenant Admin Policy` for the Administrators group should include this broad verb. Any other active policy containing this tenancy-wide permission is identified.", + "Risk": "Tenancy-wide `manage` rights for non-admins allow complete control of identities and resources. A compromised account can escalate privileges, exfiltrate data across compartments, alter or delete workloads and backups, and disable monitoring-impacting **confidentiality**, **integrity**, and **availability** of the entire tenancy.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci iam policy delete --policy-id --force", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/tenancy-administrator-group-access.html", - "Terraform": "" + "Other": "1. In OCI Console, go to Identity & Security > Policies\n2. Open each ACTIVE policy that is NOT named \"Tenant Admin Policy\"\n3. Click Edit policy\n4. Remove any statement containing: to manage all-resources in tenancy; or change it to: in compartment \n5. Click Save", + "Terraform": "```hcl\nresource \"oci_identity_policy\" \"\" {\n name = \"\"\n compartment_id = \"\"\n description = \"Replace tenancy-wide admin with scoped permissions\"\n\n statements = [\n # FIX: Replace \"in tenancy\" with compartment scope to remove overly broad permission\n \"allow group to manage all-resources in compartment \"\n ]\n}\n```" }, "Recommendation": { - "Text": "Ensure permissions on all resources are given only to the tenancy administrator group", - "Url": "https://hub.prowler.com/check/oci/identity_tenancy_admin_permissions_limited" + "Text": "Restrict `manage all-resources in tenancy` to the **Administrators** group only. Apply **least privilege** by scoping access to compartments and specific resource families, using conditions to narrow rights. Enforce **separation of duties**, maintain a *break-glass* admin model, and review policies regularly to prevent privilege drift.", + "Url": "https://hub.prowler.com/check/identity_tenancy_admin_permissions_limited" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_tenancy_admin_users_no_api_keys/identity_tenancy_admin_users_no_api_keys.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_tenancy_admin_users_no_api_keys/identity_tenancy_admin_users_no_api_keys.metadata.json index 04976ef9cb..b4667c6b2b 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_tenancy_admin_users_no_api_keys/identity_tenancy_admin_users_no_api_keys.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_tenancy_admin_users_no_api_keys/identity_tenancy_admin_users_no_api_keys.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "identity_tenancy_admin_users_no_api_keys", - "CheckTitle": "Ensure API keys are not created for tenancy administrator users", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Tenancy administrator user has no API keys", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciIamUser", + "ResourceType": "User", "ResourceGroup": "IAM", - "Description": "Tenancy administrator users should not have API keys.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI tenancy administrator accounts** (members of the `Administrators` group) are inspected for configured user **API keys** tied to those identities.", + "Risk": "**Admin API keys** are long-lived secrets that can be stolen from endpoints or repos, enabling non-interactive, MFA-less access. Attackers could run privileged API calls, exfiltrate data, modify policies, or delete resources, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to the Oracle Cloud Console\n2. Open the navigation menu and go to Identity & Security > Domains\n3. Select your domain (), then click Users\n4. Click the tenancy administrator user ()\n5. Open the API Keys tab\n6. For each API key, click Actions > Delete and confirm\n7. Repeat until the API Keys list is empty", "Terraform": "" }, "Recommendation": { - "Text": "Ensure API keys are not created for tenancy administrator users", - "Url": "https://hub.prowler.com/check/oci/identity_tenancy_admin_users_no_api_keys" + "Text": "Do not issue **API keys** to tenancy administrators. Apply **least privilege** and **separation of duties**: use non-admin service accounts or principals with narrowly scoped rights and short-lived credentials. Require **SSO/MFA** for admin access, and enforce rapid key revocation and rotation for any programmatic identities.", + "Url": "https://hub.prowler.com/check/identity_tenancy_admin_users_no_api_keys" } }, "Categories": [ - "identity-access" + "identity-access", + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/identity/identity_user_api_keys_rotated_90_days/identity_user_api_keys_rotated_90_days.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_user_api_keys_rotated_90_days/identity_user_api_keys_rotated_90_days.metadata.json index 1f03337ce8..2e3371ff62 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_user_api_keys_rotated_90_days/identity_user_api_keys_rotated_90_days.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_user_api_keys_rotated_90_days/identity_user_api_keys_rotated_90_days.metadata.json @@ -1,31 +1,30 @@ { "Provider": "oraclecloud", "CheckID": "identity_user_api_keys_rotated_90_days", - "CheckTitle": "Ensure user API keys rotate within 90 days or less", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "User active API key is rotated within 90 days or less", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciIamApiKey", + "ResourceType": "User", "ResourceGroup": "IAM", - "Description": "Ensure user API keys rotate within 90 days or less. API keys are used to authenticate API calls. For security purposes, it is recommended that API keys be rotated regularly.", - "Risk": "Having API keys that have not been rotated in over 90 days increases the risk of unauthorized access if the key is compromised.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm", + "Description": "**OCI IAM users** with **active API signing keys** older than `90` days are identified. Key age is derived from each key's creation time; only active keys are considered. Users without API keys are recorded.", + "Risk": "Long-lived API keys widen exposure. If a key leaks, an attacker can sign OCI API calls without MFA, enabling unauthorized changes (**integrity**), data access (**confidentiality**), and service outages (**availability**). Delayed rotation prolongs dwell time and complicates incident response.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm" + ], "Remediation": { "Code": { - "CLI": "oci iam api-key upload --user-id --key-file && oci iam api-key delete --user-id --fingerprint ", + "CLI": "oci iam api-key delete --user-id --fingerprint --force", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/rotate-user-api-keys.html", + "Other": "1. Sign in to the OCI Console\n2. Go to Identity & Security > Users, then select the target user\n3. Open API Keys and click Add API Key\n4. Generate API Key Pair (or Upload/Paste Public Key), then click Add and download/copy the private key\n5. For each API key older than 90 days, click the Actions (three dots) next to its fingerprint and select Delete\n6. Confirm deletion", "Terraform": "" }, "Recommendation": { - "Text": "Rotate API keys that are older than 90 days by creating a new key and deleting the old one.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/managingcredentials.htm" + "Text": "Enforce **API key rotation** every `90` days.\n- Issue a new key, confirm workloads use it, then revoke the old key\n- Apply **least privilege** and avoid shared keys\n- Limit active keys per user and remove unused ones\n- Monitor usage and automate rotation for **defense in depth**", + "Url": "https://hub.prowler.com/check/identity_user_api_keys_rotated_90_days" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_user_auth_tokens_rotated_90_days/identity_user_auth_tokens_rotated_90_days.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_user_auth_tokens_rotated_90_days/identity_user_auth_tokens_rotated_90_days.metadata.json index 63644fa279..d90143a05a 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_user_auth_tokens_rotated_90_days/identity_user_auth_tokens_rotated_90_days.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_user_auth_tokens_rotated_90_days/identity_user_auth_tokens_rotated_90_days.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "identity_user_auth_tokens_rotated_90_days", - "CheckTitle": "Ensure user auth tokens rotate within 90 days or less", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "User auth token age is 90 days or less", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciIamUser", + "ResourceType": "User", "ResourceGroup": "IAM", - "Description": "Auth tokens should be rotated within 90 days.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI IAM user auth tokens** are evaluated for **rotation age** against a `90-day` threshold using each token's creation time. Tokens older than this window indicate the token has not been recently rotated.", + "Risk": "Stale **auth tokens** increase exposure of **confidential data** and enable **persistent unauthorized API access** if compromised. Long-lived tokens can outlast password resets and role changes, weakening **least privilege** and enabling **lateral movement** and data exfiltration.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci iam user delete-auth-token --user-id --auth-token-id --force", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/rotate-user-auth-tokens.html", + "Other": "1. In the OCI Console, go to Identity & Security > Users\n2. Open the user with the failing auth token\n3. In the Auth Tokens tab, find tokens older than 90 days (check Created date)\n4. Select the old token and click Delete\n5. Confirm deletion\n6. Repeat for any other tokens older than 90 days", "Terraform": "" }, "Recommendation": { - "Text": "Ensure user auth tokens rotate within 90 days or less", - "Url": "https://hub.prowler.com/check/oci/identity_user_auth_tokens_rotated_90_days" + "Text": "Enforce routine **token rotation** at `<= 90 days` and prefer **short-lived, scoped credentials**. Apply **least privilege**, revoke unused tokens, set **automatic expirations**, and monitor usage. *When possible*, replace static tokens with federated or session-based access to reduce exposure.", + "Url": "https://hub.prowler.com/check/identity_user_auth_tokens_rotated_90_days" } }, "Categories": [ - "identity-access" + "identity-access", + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/identity/identity_user_customer_secret_keys_rotated_90_days/identity_user_customer_secret_keys_rotated_90_days.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_user_customer_secret_keys_rotated_90_days/identity_user_customer_secret_keys_rotated_90_days.metadata.json index 99c50badbf..21a3387f52 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_user_customer_secret_keys_rotated_90_days/identity_user_customer_secret_keys_rotated_90_days.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_user_customer_secret_keys_rotated_90_days/identity_user_customer_secret_keys_rotated_90_days.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "identity_user_customer_secret_keys_rotated_90_days", - "CheckTitle": "Ensure user customer secret keys rotate within 90 days or less", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "User customer secret key is rotated within 90 days or less", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciIamUser", + "ResourceType": "User", "ResourceGroup": "IAM", - "Description": "Customer secret keys should be rotated within 90 days.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI IAM customer secret keys** are assessed by creation timestamp to determine whether their age exceeds `90` days.", + "Risk": "Long-lived **customer secret keys** keep compromised or brute-forced credentials valid.\nAttackers can use them to list, read, write, or delete Object Storage data, enabling exfiltration, tampering, and disruption-impacting confidentiality, integrity, and availability.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci iam customer-secret-key delete --user-id --customer-secret-key-id --force", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/rotate-customer-secret-keys.html", + "Other": "1. Sign in to the Oracle Cloud Console\n2. Go to Identity & Security > Domains > > Users > select \n3. Open the Customer secret keys tab and click Generate secret key; save the Access Key and Secret\n4. Delete any key older than 90 days: select the old key and click Delete", "Terraform": "" }, "Recommendation": { - "Text": "Ensure user customer secret keys rotate within 90 days or less", - "Url": "https://hub.prowler.com/check/oci/identity_user_customer_secret_keys_rotated_90_days" + "Text": "Rotate **customer secret keys** every `<= 90` days. Apply **least privilege**, remove unused keys, and prefer short-lived identities (instance/resource principals) over static secrets. Automate rotation and alerts, avoid embedding keys in code, and continuously monitor key usage.", + "Url": "https://hub.prowler.com/check/identity_user_customer_secret_keys_rotated_90_days" } }, "Categories": [ - "identity-access" + "identity-access", + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/identity/identity_user_db_passwords_rotated_90_days/identity_user_db_passwords_rotated_90_days.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_user_db_passwords_rotated_90_days/identity_user_db_passwords_rotated_90_days.metadata.json index 3937820b11..edaaa480bf 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_user_db_passwords_rotated_90_days/identity_user_db_passwords_rotated_90_days.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_user_db_passwords_rotated_90_days/identity_user_db_passwords_rotated_90_days.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "identity_user_db_passwords_rotated_90_days", - "CheckTitle": "Ensure user IAM Database Passwords rotate within 90 days", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "User IAM database password was created within the last 90 days", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciIamUser", + "ResourceType": "User", "ResourceGroup": "IAM", - "Description": "Database passwords should be rotated within 90 days.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI IAM user database passwords** are evaluated for **age**. Passwords are compared to a rotation window of `90 days`, flagging credentials that have not been refreshed within that period for the associated user accounts and regions.\n\n*Covers each user's active database passwords tracked by IAM.*", + "Risk": "Stale **database credentials** increase exposure to **brute-force**, **credential stuffing**, and reuse of leaked passwords. If compromised, attackers can gain direct DB access, enabling **data exfiltration** (C), unauthorized changes to schemas or data (I), and service disruption via destructive queries (A). Long-lived secrets can bypass offboarding.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci iam db-credential create --user-id ", "NativeIaC": "", - "Other": "", + "Other": "1. Sign in to the OCI Console\n2. Go to Identity & Security > Domains and select your domain\n3. Click Users and open the target user\n4. In the user's page, open DB passwords (Database passwords)\n5. Click Create DB password (or Reset) and confirm\n6. Copy the generated password and update any services using it", "Terraform": "" }, "Recommendation": { - "Text": "Ensure user IAM Database Passwords rotate within 90 days", - "Url": "https://hub.prowler.com/check/oci/identity_user_db_passwords_rotated_90_days" + "Text": "Enforce rotation of **IAM database passwords** at or below `90 days` and expire old credentials. Automate issuance and revocation, and remove unused DB passwords.\n\nPrefer **short-lived auth** (tokens or certificates) where supported, apply **least privilege** to DB roles, and monitor credential age to prevent drift.", + "Url": "https://hub.prowler.com/check/identity_user_db_passwords_rotated_90_days" } }, "Categories": [ - "identity-access" + "identity-access", + "secrets" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/identity/identity_user_mfa_enabled_console_access/identity_user_mfa_enabled_console_access.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_user_mfa_enabled_console_access/identity_user_mfa_enabled_console_access.metadata.json index 62bc80619d..c5899816cd 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_user_mfa_enabled_console_access/identity_user_mfa_enabled_console_access.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_user_mfa_enabled_console_access/identity_user_mfa_enabled_console_access.metadata.json @@ -1,31 +1,31 @@ { "Provider": "oraclecloud", "CheckID": "identity_user_mfa_enabled_console_access", - "CheckTitle": "Ensure MFA is enabled for all users with a console password", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "User with console password has MFA enabled for console access", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciIamUser", + "ResourceType": "User", "ResourceGroup": "IAM", - "Description": "Ensure MFA is enabled for all users with a console password. Multi-factor authentication is a method of authentication that requires the use of more than one factor to verify a user's identity.", - "Risk": "Enabling MFA provides increased security by requiring two methods of verification at sign-in. With MFA enabled, a user must possess a device that emits a time-sensitive key and have knowledge of a username and password.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/usingmfa.htm", + "Description": "**OCI IAM users** with **console password access** are expected to have **multifactor authentication** enabled. The evaluation inspects each local user allowed to sign in to the Console and identifies those without an active second factor.", + "Risk": "Console-password accounts without **MFA** are exposed to **phishing**, **credential stuffing**, and **brute force**.\n\nA stolen password can grant Console access, enabling privilege escalation, key creation, and data access-compromising **confidentiality**, **integrity**, and **availability** across OCI.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/usingmfa.htm", + "https://docs.oracle.com/en-us/iaas/Content/Security/Reference/iam_security_topic-IAM_MFA.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci iam user update-user-capabilities --user-id --can-use-console-password false", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-IAM/enable-mfa-for-user-accounts.html", + "Other": "1. Enable MFA (user action):\n - Sign in to the OCI Console as the affected user\n - Click your profile icon > User settings\n - Click \"Enable Multi-Factor Authentication\"\n - Scan the QR code with an authenticator app and enter the verification code, then click \"Enable\"\n2. Or, remove console access (admin action) if the user doesn't need it:\n - In the Console, go to Identity & Security > Users\n - Open the user's page > Edit user capabilities\n - Uncheck \"Can use Console password\" and Save", "Terraform": "" }, "Recommendation": { - "Text": "Enable MFA for all users with console password access.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Identity/Tasks/usingmfa.htm" + "Text": "Require **MFA** for all users with Console passwords; prefer **phishing-resistant authenticators** (e.g., FIDO). Enforce via sign-on policies, prioritizing admins. Apply **least privilege**, disable Console passwords for service or federated users, and monitor auth logs to confirm ongoing MFA coverage.", + "Url": "https://hub.prowler.com/check/identity_user_mfa_enabled_console_access" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/identity/identity_user_valid_email_address/identity_user_valid_email_address.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_user_valid_email_address/identity_user_valid_email_address.metadata.json index 8c2f311aa8..48628bf64a 100644 --- a/prowler/providers/oraclecloud/services/identity/identity_user_valid_email_address/identity_user_valid_email_address.metadata.json +++ b/prowler/providers/oraclecloud/services/identity/identity_user_valid_email_address/identity_user_valid_email_address.metadata.json @@ -1,31 +1,30 @@ { "Provider": "oraclecloud", "CheckID": "identity_user_valid_email_address", - "CheckTitle": "Ensure all OCI IAM user accounts have a valid and current email address", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "IAM user has a valid email address", + "CheckType": [], "ServiceName": "identity", "SubServiceName": "", - "ResourceIdTemplate": "oci:identity:user", + "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "OciIamUser", + "ResourceType": "User", "ResourceGroup": "IAM", - "Description": "All user accounts should have valid email addresses.", - "Risk": "Not meeting this IAM requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm", + "Description": "**OCI IAM user accounts** are evaluated for a populated `email` attribute that resembles an address (contains `@`). Accounts missing this attribute or with malformed values are identified.", + "Risk": "Missing or invalid emails break **account recovery** and **MFA** flows, silencing security notifications. This degrades **availability** for legitimate users and enables attacker **persistence** on compromised or orphaned accounts, delaying containment and risking unauthorized data access.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci iam user update --user-id --email ", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. Sign in to the OCI Console\n2. Go to Identity & Security > Users\n3. Select the user without a valid email\n4. Click Edit (or Update)\n5. Enter a valid email address (must include '@')\n6. Click Save", + "Terraform": "```hcl\n# Ensure the user has a valid email address\nresource \"oci_identity_user\" \"\" {\n compartment_id = \"\"\n name = \"\"\n email = \"\" # Critical: sets a valid email (contains '@') to pass the check\n}\n```" }, "Recommendation": { - "Text": "Ensure all OCI IAM user accounts have a valid and current email address", - "Url": "https://hub.prowler.com/check/oci/identity_user_valid_email_address" + "Text": "Ensure every user has a unique, verified, and monitored `email`. Enforce this at creation and through periodic reviews; disable or remove accounts with invalid addresses. Prefer **federated identities** to minimize local users, and apply **least privilege** with clear ownership to support lifecycle management.", + "Url": "https://hub.prowler.com/check/identity_user_valid_email_address" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/integration/integration_instance_access_restricted/integration_instance_access_restricted.metadata.json b/prowler/providers/oraclecloud/services/integration/integration_instance_access_restricted/integration_instance_access_restricted.metadata.json index f1dc34147c..5003af480d 100644 --- a/prowler/providers/oraclecloud/services/integration/integration_instance_access_restricted/integration_instance_access_restricted.metadata.json +++ b/prowler/providers/oraclecloud/services/integration/integration_instance_access_restricted/integration_instance_access_restricted.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "integration_instance_access_restricted", - "CheckTitle": "Ensure Oracle Integration Cloud (OIC) access is restricted to allowed sources", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Integration Cloud instance uses a private endpoint or a public endpoint with IP or VCN allowlists", + "CheckType": [], "ServiceName": "integration", "SubServiceName": "", - "ResourceIdTemplate": "oci:integration:instance", + "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "IntegrationInstance", "ResourceGroup": "compute", - "Description": "Oracle Integration Cloud access should be restricted to allowed sources.", - "Risk": "Not meeting this network security requirement increases risk of unauthorized access.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/home.htm", + "Description": "**Oracle Integration Cloud instances** are evaluated for **network endpoint restrictions**, confirming access is limited to approved IPs or VCNs. Configurations with `0.0.0.0/0`, missing endpoint details, or **PUBLIC** endpoints without allowlists are identified; **PRIVATE** endpoints or PUBLIC endpoints with IP/VCN allowlists indicate restricted access.", + "Risk": "Unrestricted OIC endpoints expose integration APIs and consoles to the Internet, enabling credential brute force, token theft, and unauthorized invocations. Attackers can exfiltrate data, alter workflows, and pivot into connected backends, impacting confidentiality and integrity, and causing availability issues via abuse or DoS.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Network/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci integration integration-instance update --integration-instance-id --network-endpoint-details '{\"networkEndpointType\":\"PUBLIC\",\"allowlistedHttpIps\":[\"\"]}'", "NativeIaC": "", - "Other": "", - "Terraform": "" + "Other": "1. In the OCI Console, go to Developer Services > Integration > Integration instances\n2. Open and click Edit\n3. If using Public endpoint: add an entry under Allowed IPs (e.g., ) and remove any 0.0.0.0/0\n4. Alternatively, switch to Private endpoint if desired and select the required VCN/subnet\n5. Click Save", + "Terraform": "```hcl\n# Restrict OIC access by allowlisting specific IPs\nresource \"oci_integration_integration_instance\" \"\" {\n network_endpoint_details {\n network_endpoint_type = \"PUBLIC\"\n allowlisted_http_ips = [\"\"] # Critical: restricts access to specified IP/CIDR\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure Oracle Integration Cloud (OIC) access is restricted to allowed sources", - "Url": "https://hub.prowler.com/check/oci/integration_instance_access_restricted" + "Text": "Prefer **PRIVATE** endpoints and restrict access to specific VCNs. *If PUBLIC is required*, enforce strict IP allowlists-never `0.0.0.0/0`. Apply **least privilege** at network layers, place OIC behind **WAF/VPN**, segment with security lists, and monitor access logs. Review allowlists regularly as part of **defense in depth**.", + "Url": "https://hub.prowler.com/check/integration_instance_access_restricted" } }, "Categories": [ - "network-security" + "internet-exposed", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json b/prowler/providers/oraclecloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json index 49d4077cac..15dbc51984 100644 --- a/prowler/providers/oraclecloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json +++ b/prowler/providers/oraclecloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "kms_key_rotation_enabled", - "CheckTitle": "Ensure customer created Customer Managed Key (CMK) is rotated at least annually", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Customer-managed KMS key has rotation enabled with interval of 365 days or less", + "CheckType": [], "ServiceName": "kms", "SubServiceName": "", - "ResourceIdTemplate": "oci:kms:resource", - "Severity": "medium", - "ResourceType": "OciKmsResource", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Key", "ResourceGroup": "security", - "Description": "Customer Managed Keys should be rotated at least annually to reduce the risk of key compromise.", - "Risk": "Not meeting this requirement increases security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/", + "Description": "**OCI KMS customer-managed keys** configured for **automatic rotation**, with a rotation interval set to `<= 365` days, or **manually rotated** within the last 365 days. Some vault types do not support auto-rotation, so manual rotation is accepted as an alternative.", + "Risk": "Without regular rotation, a compromised key can be used longer to decrypt data at rest and backups or to forge signatures. This erodes **confidentiality** and **integrity**, increases the blast radius, and complicates incident response due to broad reuse of the same key version.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.cloud.oracle.com/en-us/Content/KeyManagement/Tasks/tasks_tasks_managingkeys_topic_edit_auto_key_rotation.htm", + "https://docs.public.content.oci.oraclecloud.com/en-us/Content/KeyManagement/Tasks/managingkeys_topic-To_create_a_new_key.htm" + ], "Remediation": { "Code": { - "CLI": "", - "NativeIaC": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-KMS/rotate-customer-managed-keys.html", - "Other": "", - "Terraform": "" + "CLI": "oci kms management key update --key-id --endpoint --is-auto-rotation-enabled true --auto-key-rotation-details '{\"rotationIntervalInDays\": 365}'", + "NativeIaC": "", + "Other": "1. In OCI Console, go to Identity & Security > Vault\n2. Open the vault, then under Resources select Master Encryption Keys\n3. Click the target key name\n4. For vaults that support auto-rotation: Click Edit auto-rotation settings, enable Auto rotation, set Rotation interval to 365 days (or less), and click Update\n5. For vaults that do not support auto-rotation (e.g., External or Virtual Private vaults): Click 'Rotate Key' to manually rotate the key version at least once every 365 days", + "Terraform": "```hcl\nresource \"oci_kms_key\" \"\" {\n compartment_id = \"\"\n display_name = \"\"\n management_endpoint = \"\"\n\n key_shape {\n algorithm = \"AES\"\n length = 16\n }\n\n is_auto_rotation_enabled = true # Critical: enables auto rotation\n auto_key_rotation_details {\n rotation_interval_in_days = 365 # Critical: interval <= 365 days\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure customer created Customer Managed Key (CMK) is rotated at least annually", - "Url": "https://hub.prowler.com/check/oci/kms_key_rotation_enabled" + "Text": "Enable **automatic key rotation** and set an interval `<= 365` days (*shorter for sensitive data*). For vault types that do not support auto-rotation (e.g., External or Virtual Private vaults), **manually rotate** the key at least once every 365 days. Apply **least privilege** and **separation of duties** for key administration. Monitor rotation status, retire old key versions, and ensure applications handle key versioning to prevent outages.", + "Url": "https://hub.prowler.com/check/kms_key_rotation_enabled" } }, "Categories": [ - "security-configuration" + "encryption" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py b/prowler/providers/oraclecloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py index 77ccadc386..248a91dec3 100644 --- a/prowler/providers/oraclecloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py +++ b/prowler/providers/oraclecloud/services/kms/kms_key_rotation_enabled/kms_key_rotation_enabled.py @@ -1,5 +1,7 @@ """Check Ensure customer created Customer Managed Key (CMK) is rotated at least annually.""" +from datetime import datetime, timedelta, timezone + from prowler.lib.check.models import Check, Check_Report_OCI from prowler.providers.oraclecloud.services.kms.kms_client import kms_client @@ -21,16 +23,32 @@ class kms_key_rotation_enabled(Check): compartment_id=key.compartment_id, ) - # Check if auto-rotation is enabled OR if rotation interval is set and <= 365 days - if key.is_auto_rotation_enabled or ( - key.rotation_interval_in_days is not None - and key.rotation_interval_in_days <= 365 + now = datetime.now(timezone.utc) + max_age = timedelta(days=365) + + manually_rotated_recently = ( + key.current_key_version_time_created is not None + and (now - key.current_key_version_time_created) <= max_age + ) + + if ( + key.is_auto_rotation_enabled + or ( + key.rotation_interval_in_days is not None + and key.rotation_interval_in_days <= 365 + ) + or manually_rotated_recently ): report.status = "PASS" - report.status_extended = f"KMS key '{key.name}' has rotation enabled (auto-rotation: {key.is_auto_rotation_enabled}, interval: {key.rotation_interval_in_days} days)." + if key.is_auto_rotation_enabled: + report.status_extended = f"KMS key {key.name} has auto-rotation enabled with interval of {key.rotation_interval_in_days} days." + elif manually_rotated_recently: + report.status_extended = f"KMS key {key.name} was manually rotated within the last 365 days." + else: + report.status_extended = f"KMS key {key.name} has rotation interval set to {key.rotation_interval_in_days} days." else: report.status = "FAIL" - report.status_extended = f"KMS key '{key.name}' does not have rotation enabled or rotation interval exceeds 365 days." + report.status_extended = f"KMS key {key.name} has not been rotated within the last 365 days and does not have auto-rotation enabled." findings.append(report) diff --git a/prowler/providers/oraclecloud/services/kms/kms_service.py b/prowler/providers/oraclecloud/services/kms/kms_service.py index 02e8cdfb85..c864c4a19f 100644 --- a/prowler/providers/oraclecloud/services/kms/kms_service.py +++ b/prowler/providers/oraclecloud/services/kms/kms_service.py @@ -1,5 +1,6 @@ """OCI Kms Service Module.""" +from datetime import datetime from typing import Optional import oci @@ -20,10 +21,9 @@ class Kms(OCIService): def __get_client__(self, region): """Get the Kms client for a region.""" - client_region = self.regional_clients.get(region) - if client_region: - return self._create_oci_client(oci.key_management.KmsVaultClient) - return None + return self._create_oci_client( + oci.key_management.KmsVaultClient, config_overrides={"region": region} + ) def __list_keys__(self, regional_client): """List all keys.""" @@ -78,6 +78,25 @@ class Kms(OCIService): key_id=key_summary.id ).data + # Fetch current key version to get its creation time + current_key_version_time_created = None + if ( + hasattr(key_details, "current_key_version") + and key_details.current_key_version + ): + try: + key_version = kms_management_client.get_key_version( + key_id=key_details.id, + key_version_id=key_details.current_key_version, + ).data + current_key_version_time_created = ( + key_version.time_created + ) + except Exception as version_error: + logger.warning( + f"Could not fetch key version for {key_details.id}: {version_error}" + ) + self.keys.append( Key( id=key_details.id, @@ -110,6 +129,7 @@ class Kms(OCIService): ) else None ), + current_key_version_time_created=current_key_version_time_created, ) ) except Exception as error: @@ -134,3 +154,4 @@ class Key(BaseModel): lifecycle_state: str is_auto_rotation_enabled: bool = False rotation_interval_in_days: Optional[int] = None + current_key_version_time_created: Optional[datetime] = None diff --git a/prowler/providers/oraclecloud/services/network/network_default_security_list_restricts_traffic/network_default_security_list_restricts_traffic.metadata.json b/prowler/providers/oraclecloud/services/network/network_default_security_list_restricts_traffic/network_default_security_list_restricts_traffic.metadata.json index 1dd36381f3..058cc1f8d7 100644 --- a/prowler/providers/oraclecloud/services/network/network_default_security_list_restricts_traffic/network_default_security_list_restricts_traffic.metadata.json +++ b/prowler/providers/oraclecloud/services/network/network_default_security_list_restricts_traffic/network_default_security_list_restricts_traffic.metadata.json @@ -1,35 +1,36 @@ { "Provider": "oraclecloud", "CheckID": "network_default_security_list_restricts_traffic", - "CheckTitle": "Ensure the default security list of every VCN restricts all traffic except ICMP", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Default security list restricts all traffic except ICMP within VCN", + "CheckType": [], "ServiceName": "network", "SubServiceName": "", - "ResourceIdTemplate": "oci:network:securitylist", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciNetworkSecurityList", + "ResourceType": "SecurityList", "ResourceGroup": "network", - "Description": "Ensure the default security list of every VCN restricts all traffic except ICMP within VCN. A default security list is automatically created when you create a VCN. It is recommended that the default security list restrict all traffic except for ICMP within the VCN.", - "Risk": "The default security list should not be used for any purpose other than as a fail-safe. It is recommended to create custom security lists for your resources.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securitylists.htm", + "Description": "**OCI default security list** of each VCN is evaluated to allow only VCN-internal `ICMP`. Non-`ICMP` ingress from `0.0.0.0/0` or sources outside the VCN, and non-`ICMP` egress to `0.0.0.0/0`, are considered overly permissive. Only default lists are in scope.", + "Risk": "Over-permissive default rules expose subnet workloads to the Internet and enable unrestricted outbound traffic. This risks confidentiality (unauthorized access, data exfiltration), integrity (remote exploitation, lateral movement), and availability (DoS/C2) through broad non-`ICMP` `0.0.0.0/0` paths.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securitylists.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Networking/restrict-traffic-for-default-security-lists.html" + ], "Remediation": { "Code": { - "CLI": "oci network security-list update --security-list-id --ingress-security-rules --egress-security-rules ", + "CLI": "oci network security-list update --security-list-id --ingress-security-rules '[{\"protocol\":\"1\",\"source\":\"\"}]' --egress-security-rules '[{\"protocol\":\"1\",\"destination\":\"\"}]'", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/restrict-traffic-for-default-security-lists.html", - "Terraform": "" + "Other": "1. Sign in to the OCI Console\n2. Go to Networking > Virtual Cloud Networks and select your VCN\n3. Open Security Lists and select the default security list\n4. Click Edit All Rules\n5. Remove all non-ICMP ingress and egress rules\n6. Add an ingress rule: Protocol ICMP, Source \n7. Add an egress rule: Protocol ICMP, Destination \n8. Save changes", + "Terraform": "```hcl\nresource \"oci_core_default_security_list\" \"\" {\n manage_default_resource_id = \"\" # Critical: targets the default security list\n\n ingress_security_rules {\n protocol = \"1\" # Critical: allow only ICMP\n source = \"\" # Critical: restrict to VCN\n }\n\n egress_security_rules {\n protocol = \"1\" # Critical: allow only ICMP\n destination = \"\" # Critical: restrict to VCN\n }\n}\n```" }, "Recommendation": { - "Text": "Configure the default security list to restrict all traffic except ICMP within the VCN. Create custom security lists for your resources.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securitylists.htm" + "Text": "Adopt a **deny-by-default** stance on the default list: allow only VCN-local `ICMP` for diagnostics. Use dedicated security lists or **network security groups** per application to explicitly allow needed ports and sources, and restrict egress to approved ranges. Review regularly under **least privilege**.", + "Url": "https://hub.prowler.com/check/network_default_security_list_restricts_traffic" } }, "Categories": [ - "network-security" + "internet-exposed", + "trust-boundaries" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/network/network_security_group_ingress_from_internet_to_rdp_port/network_security_group_ingress_from_internet_to_rdp_port.metadata.json b/prowler/providers/oraclecloud/services/network/network_security_group_ingress_from_internet_to_rdp_port/network_security_group_ingress_from_internet_to_rdp_port.metadata.json index f0223a9cc0..b56494d8a2 100644 --- a/prowler/providers/oraclecloud/services/network/network_security_group_ingress_from_internet_to_rdp_port/network_security_group_ingress_from_internet_to_rdp_port.metadata.json +++ b/prowler/providers/oraclecloud/services/network/network_security_group_ingress_from_internet_to_rdp_port/network_security_group_ingress_from_internet_to_rdp_port.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "network_security_group_ingress_from_internet_to_rdp_port", - "CheckTitle": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 3389", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Network security group restricts ingress from 0.0.0.0/0 to port 3389 (RDP)", + "CheckType": [], "ServiceName": "network", "SubServiceName": "", - "ResourceIdTemplate": "oci:network:vcn", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciVcn", + "ResourceType": "NetworkSecurityGroup", "ResourceGroup": "network", - "Description": "Network security groups should not allow unrestricted RDP access from the internet.", - "Risk": "Not meeting this network security requirement increases risk of unauthorized access.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/home.htm", + "Description": "**OCI Network Security Groups** are evaluated for inbound source `0.0.0.0/0` permitting **RDP** on `TCP 3389`, including broad rules (all TCP or any protocol) that implicitly include that port.", + "Risk": "Exposed **RDP** enables Internet-scale **brute-force** and **protocol exploit** attempts. Compromise yields interactive access for **data exfiltration** (C), **unauthorized changes** (I), and **service disruption** via ransomware (A), with potential **lateral movement** across the VCN.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Network/home.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Networking/unrestricted-rdp-access-via-nsgs.html" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci network nsg rules delete --network-security-group-id --security-rule-id ", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/unrestricted-rdp-access-via-nsgs.html", - "Terraform": "" + "Other": "1. In the OCI Console, go to Networking > Network Security Groups\n2. Open the \n3. Click Security Rules, then the Ingress tab\n4. Find the rule with Source 0.0.0.0/0 and Protocol TCP (or All) allowing port 3389\n5. Click Delete to remove the rule\n - If RDP is required, Edit instead and set Source to a specific CIDR (not 0.0.0.0/0)\n6. Save changes", + "Terraform": "```hcl\nresource \"oci_core_network_security_group_security_rule\" \"\" {\n network_security_group_id = \"\"\n direction = \"INGRESS\"\n protocol = \"6\" # TCP\n source = \"10.0.0.0/8\" # Critical: not 0.0.0.0/0; restricts RDP to an internal CIDR to remove internet exposure\n\n tcp_options {\n destination_port_range {\n min = 3389 # Critical: explicit RDP port\n max = 3389 # Critical: ensures only 3389 is allowed\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 3389", - "Url": "https://hub.prowler.com/check/oci/network_security_group_ingress_from_internet_to_rdp_port" + "Text": "Eliminate open `0.0.0.0/0` rules. Restrict **RDP** to trusted IPs and only when necessary. Prefer **private connectivity** via **VPN**, **bastion/jump hosts**, or **zero-trust** brokers. Apply **least privilege** and time-bound access, monitor logs, and disable RDP where unnecessary.", + "Url": "https://hub.prowler.com/check/network_security_group_ingress_from_internet_to_rdp_port" } }, "Categories": [ - "network-security" + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/network/network_security_group_ingress_from_internet_to_ssh_port/network_security_group_ingress_from_internet_to_ssh_port.metadata.json b/prowler/providers/oraclecloud/services/network/network_security_group_ingress_from_internet_to_ssh_port/network_security_group_ingress_from_internet_to_ssh_port.metadata.json index f5ccc596b4..089ec46c88 100644 --- a/prowler/providers/oraclecloud/services/network/network_security_group_ingress_from_internet_to_ssh_port/network_security_group_ingress_from_internet_to_ssh_port.metadata.json +++ b/prowler/providers/oraclecloud/services/network/network_security_group_ingress_from_internet_to_ssh_port/network_security_group_ingress_from_internet_to_ssh_port.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "network_security_group_ingress_from_internet_to_ssh_port", - "CheckTitle": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 22", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Network security group restricts ingress from 0.0.0.0/0 to port 22 (SSH)", + "CheckType": [], "ServiceName": "network", "SubServiceName": "", - "ResourceIdTemplate": "oci:network:networksecuritygroup", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciNetworkSecurityGroup", + "ResourceType": "NetworkSecurityGroup", "ResourceGroup": "network", - "Description": "Ensure no network security groups allow ingress from 0.0.0.0/0 to port 22. Network security groups provide stateful or stateless filtering of ingress and egress network traffic to OCI resources.", - "Risk": "Removing unfettered connectivity to remote console services, such as SSH, reduces a server's exposure to risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/networksecuritygroups.htm", + "Description": "Network security groups with **ingress** from `0.0.0.0/0` exposing **SSH** are identified. This includes rules that explicitly permit `TCP` `22`, use `all` protocols, or define TCP port ranges that encompass `22`.", + "Risk": "Public **SSH** access enables Internet-wide probing, **brute force**, and **credential stuffing** that can grant shell access. Compromise allows **lateral movement**, data exfiltration (confidentiality), unauthorized changes or malware (integrity), and service disruption or ransomware-driven lockouts (availability).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Networking/unrestricted-ssh-access-via-nsgs.html", + "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/networksecuritygroups.htm" + ], "Remediation": { "Code": { - "CLI": "oci network nsg rules update --nsg-id --security-rules ", + "CLI": "oci network nsg rules remove --nsg-id --security-rule-ids '[\"\"]'", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/unrestricted-ssh-access-via-nsgs.html", - "Terraform": "" + "Other": "1. In the OCI Console, go to Networking > Network Security Groups\n2. Open the target NSG and go to the Rules tab\n3. Find the INGRESS rule with Source 0.0.0.0/0 that includes TCP port 22\n4. Click Remove to delete that rule\n5. (If SSH is required) Add a new INGRESS rule for TCP port 22 with Source set to a specific trusted CIDR (not 0.0.0.0/0), then Save", + "Terraform": "```hcl\n# Restrict SSH so it's not from 0.0.0.0/0\nresource \"oci_core_network_security_group_security_rule\" \"\" {\n network_security_group_id = \"\"\n direction = \"INGRESS\"\n protocol = \"6\" # TCP\n source = \"10.0.0.0/8\" # CRITICAL: not 0.0.0.0/0; limits SSH to internal range\n\n tcp_options {\n destination_port_range {\n min = 22 # CRITICAL: explicitly defines SSH port\n max = 22\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Update network security groups to remove ingress rules allowing access from 0.0.0.0/0 to port 22. Restrict SSH access to known IP addresses.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/networksecuritygroups.htm" + "Text": "Enforce **least privilege**: limit SSH in NSG rules to trusted CIDRs or private networks. Prefer **bastion access** (e.g., OCI Bastion) or **VPN/private connectivity** over direct Internet exposure. Use key-based auth, disable `PasswordAuthentication`, log and monitor access, and segment workloads for **defense in depth**.", + "Url": "https://hub.prowler.com/check/network_security_group_ingress_from_internet_to_ssh_port" } }, "Categories": [ - "internet-exposed", - "network-security" + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/network/network_security_list_ingress_from_internet_to_rdp_port/network_security_list_ingress_from_internet_to_rdp_port.metadata.json b/prowler/providers/oraclecloud/services/network/network_security_list_ingress_from_internet_to_rdp_port/network_security_list_ingress_from_internet_to_rdp_port.metadata.json index c1965ebdf7..16b0f664ce 100644 --- a/prowler/providers/oraclecloud/services/network/network_security_list_ingress_from_internet_to_rdp_port/network_security_list_ingress_from_internet_to_rdp_port.metadata.json +++ b/prowler/providers/oraclecloud/services/network/network_security_list_ingress_from_internet_to_rdp_port/network_security_list_ingress_from_internet_to_rdp_port.metadata.json @@ -1,35 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "network_security_list_ingress_from_internet_to_rdp_port", - "CheckTitle": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 3389", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Security list restricts ingress from 0.0.0.0/0 to port 3389 (RDP)", + "CheckType": [], "ServiceName": "network", "SubServiceName": "", - "ResourceIdTemplate": "oci:network:vcn", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciVcn", + "ResourceType": "SecurityList", "ResourceGroup": "network", - "Description": "Security lists should not allow unrestricted RDP access from the internet.", - "Risk": "Not meeting this network security requirement increases risk of unauthorized access.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/home.htm", + "Description": "**OCI security lists** are evaluated for rules that permit **inbound RDP** from the Internet: any source `0.0.0.0/0` allowing **TCP 3389**-including rules that allow all TCP ports or all protocols-signals public RDP exposure.", + "Risk": "Exposed RDP enables:\n- **Brute-force/credential stuffing** to gain console access\n- **RCE via RDP flaws** and **lateral movement**\n\nImpact: data exfiltration (confidentiality), unauthorized changes (integrity), and disruption or ransomware (availability).", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Network/home.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Networking/unrestricted-rdp-access.html" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/unrestricted-rdp-access.html", - "Terraform": "" + "Other": "1. In the OCI Console, go to Networking > Virtual Cloud Networks and select your VCN\n2. Under Resources, click Security Lists and open the list attached to the affected subnet\n3. In Ingress Rules, locate any rule with Source CIDR 0.0.0.0/0 that allows TCP port 3389 or All Protocols\n4. Delete that rule, or edit it so Source CIDR is a restricted range (e.g., your IP) and Destination Port is 3389 only\n5. Click Save Changes", + "Terraform": "```hcl\nresource \"oci_core_security_list\" \"\" {\n compartment_id = \"\"\n vcn_id = \"\"\n\n ingress_security_rules {\n protocol = \"6\" # TCP\n source = \"10.0.0.0/8\" # CRITICAL: not 0.0.0.0/0; restricts RDP to trusted range to avoid open Internet\n tcp_options {\n destination_port_range {\n min = 3389 # CRITICAL: limit to RDP port only\n max = 3389\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 3389", - "Url": "https://hub.prowler.com/check/oci/network_security_list_ingress_from_internet_to_rdp_port" + "Text": "Block Internet access to RDP. \n- Deny `0.0.0.0/0` to `3389`; allow only trusted CIDRs.\n- Prefer **private access** via a bastion, VPN, or zero-trust remote management.\n- Enforce **least privilege** network rules, use **defense in depth**, and adopt *just-in-time* administrative access.", + "Url": "https://hub.prowler.com/check/network_security_list_ingress_from_internet_to_rdp_port" } }, "Categories": [ - "network-security" + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/network/network_security_list_ingress_from_internet_to_ssh_port/network_security_list_ingress_from_internet_to_ssh_port.metadata.json b/prowler/providers/oraclecloud/services/network/network_security_list_ingress_from_internet_to_ssh_port/network_security_list_ingress_from_internet_to_ssh_port.metadata.json index 59b76f20fe..fa01648737 100644 --- a/prowler/providers/oraclecloud/services/network/network_security_list_ingress_from_internet_to_ssh_port/network_security_list_ingress_from_internet_to_ssh_port.metadata.json +++ b/prowler/providers/oraclecloud/services/network/network_security_list_ingress_from_internet_to_ssh_port/network_security_list_ingress_from_internet_to_ssh_port.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "network_security_list_ingress_from_internet_to_ssh_port", - "CheckTitle": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 22", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Security list restricts ingress from 0.0.0.0/0 to port 22 (SSH)", + "CheckType": [], "ServiceName": "network", "SubServiceName": "", - "ResourceIdTemplate": "oci:network:securitylist", + "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "OciNetworkSecurityList", + "ResourceType": "SecurityList", "ResourceGroup": "network", - "Description": "Ensure no security lists allow ingress from 0.0.0.0/0 to port 22. Security lists provide stateful or stateless filtering of ingress and egress network traffic to OCI resources.", - "Risk": "Removing unfettered connectivity to remote console services, such as SSH, reduces a server's exposure to risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securitylists.htm", + "Description": "**OCI security lists** are evaluated for rules that permit **inbound SSH** from `0.0.0.0/0`. Any rule where the destination includes `TCP 22`-or broader rules allowing all TCP or all protocols from `0.0.0.0/0`-indicates public SSH exposure.", + "Risk": "**Public SSH access** enables Internet-wide **brute force** and **credential stuffing**, risking unauthorized shell access. Compromise can cause data exfiltration (**confidentiality**), command tampering (**integrity**), service disruption (**availability**), and lateral movement in the VCN.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securitylists.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Networking/unrestricted-ssh-access.html" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci network security-list update --security-list-id --ingress-security-rules '[{\"protocol\":\"6\",\"source\":\"\",\"tcp-options\":{\"destination-port-range\":{\"min\":22,\"max\":22}}}]'", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/unrestricted-ssh-access.html", - "Terraform": "" + "Other": "1. In the OCI Console, go to Networking > Virtual Cloud Networks and open your VCN\n2. Click Security Lists, then select \n3. In Ingress Rules, locate any rule with Source CIDR 0.0.0.0/0 and Protocol TCP (or All) that includes port 22\n4. Edit the rule: change Source CIDR to a specific allowed range (e.g., your office IP/CIDR) and set Destination Port Range to 22, or delete the rule\n5. Click Save changes", + "Terraform": "```hcl\nresource \"oci_core_security_list\" \"\" {\n compartment_id = \"\"\n vcn_id = \"\"\n\n ingress_security_rules {\n protocol = \"6\"\n source = \"\" # Critical: restrict source; do not use 0.0.0.0/0 to block public SSH\n\n tcp_options {\n destination_port_range {\n min = 22 # Critical: only SSH port\n max = 22\n }\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Update security lists to remove ingress rules allowing access from 0.0.0.0/0 to port 22. Restrict SSH access to known IP addresses.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Network/Concepts/securitylists.htm" + "Text": "Restrict **SSH** to trusted sources using least-privilege network rules; avoid `0.0.0.0/0`. Prefer **private access** via VPN/peering or a hardened **bastion**. Apply **network segmentation** (NSGs/security lists) to narrow scope. Enforce **key-based authentication**, disable password login, and monitor access.", + "Url": "https://hub.prowler.com/check/network_security_list_ingress_from_internet_to_ssh_port" } }, "Categories": [ - "internet-exposed", - "network-security" + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/network/network_vcn_subnet_flow_logs_enabled/network_vcn_subnet_flow_logs_enabled.metadata.json b/prowler/providers/oraclecloud/services/network/network_vcn_subnet_flow_logs_enabled/network_vcn_subnet_flow_logs_enabled.metadata.json index 6b0ff9ae32..f7b886cb24 100644 --- a/prowler/providers/oraclecloud/services/network/network_vcn_subnet_flow_logs_enabled/network_vcn_subnet_flow_logs_enabled.metadata.json +++ b/prowler/providers/oraclecloud/services/network/network_vcn_subnet_flow_logs_enabled/network_vcn_subnet_flow_logs_enabled.metadata.json @@ -1,35 +1,37 @@ { "Provider": "oraclecloud", "CheckID": "network_vcn_subnet_flow_logs_enabled", - "CheckTitle": "Ensure VCN flow logging is enabled for all subnets", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Subnet has VCN flow logging enabled", + "CheckType": [], "ServiceName": "network", "SubServiceName": "", - "ResourceIdTemplate": "oci:network:subnet", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciSubnet", + "ResourceType": "Subnet", "ResourceGroup": "network", - "Description": "VCN flow logging should be enabled for all subnets.", - "Risk": "Not meeting this network security requirement increases risk of unauthorized access.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Network/home.htm", + "Description": "**OCI subnets** in a VCN have **network flow logging** configured. Evaluation considers an active `flowlogs` configuration targeting the `VCN` or the specific `subnet` in the same region and associated with a log group.", + "Risk": "Without flow logs, east-west and ingress/egress traffic is opaque, weakening detection and forensics. Scans, data exfiltration, and lateral movement can go unnoticed, undermining confidentiality and integrity, and delaying response to availability threats like DDoS.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.ateam-oracle.com/post/enable-oci-flow-logs-for-all-subnets-and-stream-logs-to-object-storage-via-service-connector-hub", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-Networking/enable-flow-logging.html", + "https://docs.oracle.com/en-us/iaas/Content/Network/Tasks/vcn-flow-logs-enable.htm" + ], "Remediation": { "Code": { "CLI": "", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-Networking/enable-flow-logging.html", - "Terraform": "" + "Other": "1. In OCI Console, go to Networking > Flow logs\n2. Click Enable flow logs\n3. Select a Log group (or create one) and a Capture filter (or create one)\n4. Click Next, then Add enablement points\n5. Choose Virtual cloud network and select your VCN (or choose Subnet and select the subnet)\n6. Click Add enablement points, then Next, then Enable flow logs", + "Terraform": "```hcl\n# Enable VCN flow logs (covers all subnets in the VCN)\nresource \"oci_logging_log\" \"\" {\n display_name = \"\"\n log_group_id = \"\" # Existing Log Group OCID\n log_type = \"SERVICE\"\n is_enabled = true\n\n configuration {\n source {\n source_type = \"OCISERVICE\"\n service = \"flowlogs\" # CRITICAL: enables VCN/Subnet flow logging\n resource = \"\" # CRITICAL: VCN OCID (use subnet OCID if enabling per-subnet)\n category = \"vcn\" # CRITICAL: set to \"vcn\" (or \"subnet\" for subnet-level)\n parameters = { capture_filter = \"\" } # Capture Filter OCID\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Ensure VCN flow logging is enabled for all subnets", - "Url": "https://hub.prowler.com/check/oci/network_vcn_subnet_flow_logs_enabled" + "Text": "Enable **VCN flow logs** for all subnets-prefer VCN-wide enablement for complete coverage-and route to centralized log groups. Enforce least privilege on log access, set retention, and integrate with a SIEM. Use `capture filters` and `sampling` to control volume. Review logs to support defense-in-depth.", + "Url": "https://hub.prowler.com/check/network_vcn_subnet_flow_logs_enabled" } }, "Categories": [ - "network-security" + "logging", + "forensics-ready" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_encrypted_with_cmk/objectstorage_bucket_encrypted_with_cmk.metadata.json b/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_encrypted_with_cmk/objectstorage_bucket_encrypted_with_cmk.metadata.json index b811ea9e3a..d05762f073 100644 --- a/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_encrypted_with_cmk/objectstorage_bucket_encrypted_with_cmk.metadata.json +++ b/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_encrypted_with_cmk/objectstorage_bucket_encrypted_with_cmk.metadata.json @@ -1,35 +1,34 @@ { "Provider": "oraclecloud", "CheckID": "objectstorage_bucket_encrypted_with_cmk", - "CheckTitle": "Ensure Object Storage Buckets are encrypted with a Customer Managed Key (CMK)", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Object Storage bucket is encrypted with a Customer Managed Key (CMK)", + "CheckType": [], "ServiceName": "objectstorage", "SubServiceName": "", - "ResourceIdTemplate": "oci:objectstorage:bucket", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciBucket", + "ResourceType": "Bucket", "ResourceGroup": "storage", - "Description": "Object Storage buckets should be encrypted with Customer Managed Keys.", - "Risk": "Not meeting this storage security requirement increases data security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Object/home.htm", + "Description": "**OCI Object Storage buckets** use **customer-managed encryption keys** (`CMEK`) for server-side encryption, with an associated KMS key configured on the bucket.", + "Risk": "Without `CMEK`, encryption relies on provider-managed keys, reducing control over **confidentiality** and key lifecycle. You cannot strictly limit key usage, enforce custom rotation, or revoke keys for crypto-erasure, increasing exposure to unauthorized decryption, data exfiltration, and auditability gaps.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-ObjectStorage/buckets-encrypted-with-cmks.html", + "https://docs.oracle.com/en-us/iaas/Content/Object/home.htm" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci os bucket update --namespace-name --bucket-name --kms-key-id ", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-ObjectStorage/buckets-encrypted-with-cmks.html", - "Terraform": "" + "Other": "1. Sign in to the OCI Console\n2. Go to Storage > Object Storage & Archive Storage > Buckets\n3. Open the target bucket\n4. Click Edit bucket\n5. Under Encryption, select Customer-managed key and choose the desired Vault key\n6. Click Save", + "Terraform": "```hcl\nresource \"oci_objectstorage_bucket\" \"\" {\n compartment_id = \"\"\n name = \"\"\n namespace = \"\"\n\n kms_key_id = \"\" # Critical: sets the Customer Managed Key to encrypt the bucket\n}\n```" }, "Recommendation": { - "Text": "Ensure Object Storage Buckets are encrypted with a Customer Managed Key (CMK)", - "Url": "https://hub.prowler.com/check/oci/objectstorage_bucket_encrypted_with_cmk" + "Text": "Encrypt buckets with `CMEK`. Apply **least privilege** to key usage, enforce **separation of duties** between key and storage admins, mandate regular rotation, and monitor key access. Use **defense in depth** so encryption complements strict IAM and network controls rather than replacing them.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_encrypted_with_cmk" } }, "Categories": [ - "storage", "encryption" ], "DependsOn": [], diff --git a/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_logging_enabled/objectstorage_bucket_logging_enabled.metadata.json b/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_logging_enabled/objectstorage_bucket_logging_enabled.metadata.json index fdad66b3c1..348e5a0022 100644 --- a/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_logging_enabled/objectstorage_bucket_logging_enabled.metadata.json +++ b/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_logging_enabled/objectstorage_bucket_logging_enabled.metadata.json @@ -1,27 +1,31 @@ { "Provider": "oraclecloud", "CheckID": "objectstorage_bucket_logging_enabled", - "CheckTitle": "Ensure write level Object Storage logging is enabled for all buckets", + "CheckTitle": "Object Storage bucket has write-level logging enabled", "CheckType": [], "ServiceName": "objectstorage", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciObjectStorageBucket", + "ResourceType": "Bucket", "ResourceGroup": "storage", - "Description": "Write-level logging for Object Storage buckets provides an audit trail of all write operations (PUT, POST, DELETE) performed on buckets, enabling security monitoring and compliance requirements.", - "Risk": "Without write-level logging, unauthorized or malicious modifications to Object Storage data cannot be detected or investigated.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Logging/Concepts/loggingoverview.htm", + "Description": "**OCI Object Storage buckets** have service logs for **write access events** enabled.\n\nThe evaluation identifies buckets with an active `write` logging category scoped to the bucket and region; only `read` logging does not satisfy this condition.", + "Risk": "Without **write logging**, unauthorized or accidental overwrites and deletions can go **undetected**, degrading **data integrity** and **availability**.\n\nMissing audit evidence weakens **non-repudiation**, impedes incident response, and allows covert tampering without reliable forensic reconstruction.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-ObjectStorage/enable-write-level-logging.html", + "https://docs.oracle.com/en-us/iaas/Content/Logging/Concepts/loggingoverview.htm" + ], "Remediation": { "Code": { - "CLI": "oci logging log create --log-group-id --display-name 'ObjectStorage-Write-Logs' --log-type SERVICE --configuration '{\"compartmentId\":\"\",\"source\":{\"service\":\"objectstorage\",\"resource\":\"\",\"category\":\"write\",\"sourceType\":\"OCISERVICE\"}}'", + "CLI": "oci logging log create --log-group-id --display-name ObjectStorage-Write-Logs --log-type SERVICE --configuration '{\"compartmentId\":\"\",\"source\":{\"service\":\"objectstorage\",\"resource\":\"\",\"category\":\"write\",\"sourceType\":\"OCISERVICE\"}}'", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-ObjectStorage/enable-write-level-logging.html", - "Terraform": "resource \"oci_logging_log\" \"objectstorage_write_log\" {\n display_name = \"ObjectStorage-Write-Logs\"\n log_group_id = oci_logging_log_group.log_group.id\n log_type = \"SERVICE\"\n configuration {\n source {\n category = \"write\"\n resource = oci_objectstorage_bucket.bucket.name\n service = \"objectstorage\"\n source_type = \"OCISERVICE\"\n }\n compartment_id = var.compartment_id\n }\n is_enabled = true\n}" + "Other": "1. In the OCI Console, select the target region and go to Observability & Management > Logging > Log groups\n2. Open an existing log group or click Create log group\n3. Click Create log\n4. Type: Service\n5. Service: Object Storage\n6. Category: write\n7. Resource: select the target bucket (bucket name must match)\n8. Ensure Enabled is checked\n9. Click Create", + "Terraform": "```hcl\nresource \"oci_logging_log\" \"\" {\n display_name = \"ObjectStorage-Write-Logs\"\n log_group_id = \"\"\n log_type = \"SERVICE\"\n\n configuration {\n compartment_id = \"\"\n source {\n service = \"objectstorage\" # Critical: Service must be Object Storage\n category = \"write\" # Critical: Enable write-level logging\n resource = \"\" # Critical: Bucket name must match the target bucket\n source_type = \"OCISERVICE\"\n }\n }\n}\n```" }, "Recommendation": { - "Text": "Enable write-level logging for all Object Storage buckets to maintain audit trails of data modifications.", - "Url": "https://docs.prowler.com/checks/oci/oci-logging/objectstorage_bucket_logging_enabled" + "Text": "Enable `write` service logs on all buckets and route them to a centralized log group for monitoring.\n\nApply **least privilege** to log data, enforce retention and immutability, and alert on anomalous write activity. Use **defense in depth** so bucket changes are accountable and swiftly detected.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_logging_enabled" } }, "Categories": [ diff --git a/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_not_publicly_accessible/objectstorage_bucket_not_publicly_accessible.metadata.json b/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_not_publicly_accessible/objectstorage_bucket_not_publicly_accessible.metadata.json index 59e6a10355..d49bc714d4 100644 --- a/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_not_publicly_accessible/objectstorage_bucket_not_publicly_accessible.metadata.json +++ b/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_not_publicly_accessible/objectstorage_bucket_not_publicly_accessible.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "objectstorage_bucket_not_publicly_accessible", - "CheckTitle": "Ensure no Object Storage buckets are publicly visible", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Object Storage bucket is not publicly accessible", + "CheckType": [], "ServiceName": "objectstorage", "SubServiceName": "", - "ResourceIdTemplate": "oci:objectstorage:bucket", + "ResourceIdTemplate": "", "Severity": "critical", - "ResourceType": "OciObjectStorageBucket", + "ResourceType": "Bucket", "ResourceGroup": "storage", - "Description": "Ensure no Object Storage buckets are publicly visible. Public access to Object Storage buckets can lead to unauthorized data access or data leakage.", - "Risk": "Publicly accessible Object Storage buckets can expose sensitive data to unauthorized users on the internet.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/managingbuckets.htm", + "Description": "**OCI Object Storage buckets** are assessed for **public accessibility**. Buckets configured as `NoPublicAccess` deny anonymous reads; any other public access setting indicates bucket contents may be reachable without authentication.", + "Risk": "**Public buckets** enable unauthenticated downloads and content listing, compromising **confidentiality** and exposing metadata. Hotlinking can drive unexpected **egress costs** and degrade **availability** through bandwidth exhaustion and service abuse.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/managingbuckets.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-ObjectStorage/publicly-accessible-buckets.html" + ], "Remediation": { "Code": { - "CLI": "oci os bucket update --namespace --bucket-name --public-access-type NoPublicAccess", + "CLI": "oci os bucket update --namespace-name --bucket-name --public-access-type NoPublicAccess", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-ObjectStorage/publicly-accessible-buckets.html", - "Terraform": "" + "Other": "1. Sign in to the OCI Console\n2. Go to Object Storage > Buckets and open \n3. Click Edit (Bucket details)\n4. Set Public access type to \"No public access\"\n5. Click Save", + "Terraform": "```hcl\nresource \"oci_objectstorage_bucket\" \"\" {\n compartment_id = \"\"\n name = \"\"\n namespace = \"\"\n public_access_type = \"NoPublicAccess\" # Critical: makes the bucket private\n}\n```" }, "Recommendation": { - "Text": "Update the bucket's public access type to 'NoPublicAccess' to prevent unauthorized access.", - "Url": "https://docs.oracle.com/en-us/iaas/Content/Object/Tasks/managingbuckets.htm" + "Text": "Keep buckets **private** (`NoPublicAccess`) under the **least privilege** principle. For external sharing, use **pre-authenticated requests** or signed URLs with scoped permissions and expiry. Restrict access via IAM policies, enforce guardrails (*e.g.*, Security Zones), and regularly review bucket visibility.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_not_publicly_accessible" } }, "Categories": [ - "internet-exposed", - "encryption" + "internet-exposed" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_versioning_enabled/objectstorage_bucket_versioning_enabled.metadata.json b/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_versioning_enabled/objectstorage_bucket_versioning_enabled.metadata.json index 1295f9cfd9..5418e08375 100644 --- a/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_versioning_enabled/objectstorage_bucket_versioning_enabled.metadata.json +++ b/prowler/providers/oraclecloud/services/objectstorage/objectstorage_bucket_versioning_enabled/objectstorage_bucket_versioning_enabled.metadata.json @@ -1,36 +1,35 @@ { "Provider": "oraclecloud", "CheckID": "objectstorage_bucket_versioning_enabled", - "CheckTitle": "Ensure Versioning is Enabled for Object Storage Buckets", - "CheckType": [ - "Software and Configuration Checks", - "Industry and Regulatory Standards", - "CIS OCI Foundations Benchmark" - ], + "CheckTitle": "Object Storage bucket has versioning enabled", + "CheckType": [], "ServiceName": "objectstorage", "SubServiceName": "", - "ResourceIdTemplate": "oci:objectstorage:bucket", + "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "OciBucket", + "ResourceType": "Bucket", "ResourceGroup": "storage", - "Description": "Object Storage buckets should have versioning enabled.", - "Risk": "Not meeting this storage security requirement increases data security risk.", - "RelatedUrl": "https://docs.oracle.com/en-us/iaas/Content/Object/home.htm", + "Description": "**OCI Object Storage buckets** are assessed for **versioning** being set to `Enabled`, indicating prior object versions are retained when updates or deletions occur.", + "Risk": "**No versioning** lets overwrites or deletions permanently remove data, harming **availability** and **integrity**. Malicious or accidental actions, automated jobs, or malware can wipe or corrupt objects without rollback, enabling **ransomware-style** encryption and large-scale data loss.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Object/home.htm", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/oci/OCI-ObjectStorage/enable-versioning.html" + ], "Remediation": { "Code": { - "CLI": "", + "CLI": "oci os bucket update --namespace-name --bucket-name --versioning Enabled", "NativeIaC": "", - "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/oci/OCI-ObjectStorage/enable-versioning.html", - "Terraform": "" + "Other": "1. Sign in to the OCI Console\n2. Go to Storage > Buckets and open the target bucket\n3. In Bucket details, find Versioning and click Edit\n4. Select Enabled and click Save", + "Terraform": "```hcl\nresource \"oci_objectstorage_bucket\" \"\" {\n compartment_id = \"\"\n name = \"\"\n namespace = \"\"\n versioning = \"Enabled\" # Critical: enables bucket versioning to pass the check\n}\n```" }, "Recommendation": { - "Text": "Ensure Versioning is Enabled for Object Storage Buckets", - "Url": "https://hub.prowler.com/check/oci/objectstorage_bucket_versioning_enabled" + "Text": "Enable **bucket versioning** (`Enabled`) for data that needs recovery. Apply **least privilege** to delete and overwrite actions, use **retention rules** or legal holds for critical data, and add **lifecycle policies** to manage older versions-providing **defense in depth** against destructive changes.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_versioning_enabled" } }, "Categories": [ - "storage", - "encryption" + "resilience" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/vercel/__init__.py b/prowler/providers/vercel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/exceptions/__init__.py b/prowler/providers/vercel/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/exceptions/exceptions.py b/prowler/providers/vercel/exceptions/exceptions.py new file mode 100644 index 0000000000..f876f778bb --- /dev/null +++ b/prowler/providers/vercel/exceptions/exceptions.py @@ -0,0 +1,127 @@ +# Exceptions codes from 13000 to 13999 are reserved for Vercel exceptions +from prowler.exceptions.exceptions import ProwlerException + + +class VercelBaseException(ProwlerException): + """Base exception for Vercel provider errors.""" + + VERCEL_ERROR_CODES = { + (13000, "VercelCredentialsError"): { + "message": "Vercel credentials not found or invalid.", + "remediation": "Set the VERCEL_TOKEN environment variable with a valid Vercel API token. Generate one at https://vercel.com/account/tokens.", + }, + (13001, "VercelAuthenticationError"): { + "message": "Authentication to Vercel API failed.", + "remediation": "Verify your Vercel API token is valid and has not expired. Check at https://vercel.com/account/tokens.", + }, + (13002, "VercelSessionError"): { + "message": "Failed to create a Vercel API session.", + "remediation": "Check network connectivity and ensure the Vercel API is reachable at https://api.vercel.com.", + }, + (13003, "VercelIdentityError"): { + "message": "Failed to retrieve Vercel identity information.", + "remediation": "Ensure the API token has permissions to read user and team information.", + }, + (13004, "VercelInvalidTeamError"): { + "message": "The specified Vercel team was not found or is not accessible.", + "remediation": "Verify the team ID or slug is correct and that your token has access to the team.", + }, + (13005, "VercelInvalidProviderIdError"): { + "message": "The provided Vercel provider ID is invalid.", + "remediation": "Ensure the provider UID matches a valid Vercel team ID or user ID format.", + }, + (13006, "VercelAPIError"): { + "message": "An error occurred while calling the Vercel API.", + "remediation": "Check the Vercel API status at https://www.vercel-status.com/ and retry the request.", + }, + (13007, "VercelRateLimitError"): { + "message": "Rate limited by the Vercel API.", + "remediation": "Wait and retry. Vercel API rate limits vary by endpoint. See https://vercel.com/docs/rest-api#rate-limits.", + }, + (13008, "VercelPlanLimitationError"): { + "message": "This feature requires a higher Vercel plan.", + "remediation": "Some security features (e.g., WAF managed rulesets) require Vercel Enterprise. Upgrade your plan or skip these checks.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "Vercel" + error_info = self.VERCEL_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Vercel error.", + "remediation": "Check the Vercel API documentation for more details.", + } + elif message: + error_info = error_info.copy() + error_info["message"] = message + super().__init__( + code=code, + source=provider, + file=file, + original_exception=original_exception, + error_info=error_info, + ) + + +class VercelCredentialsError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13000, file=file, original_exception=original_exception, message=message + ) + + +class VercelAuthenticationError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13001, file=file, original_exception=original_exception, message=message + ) + + +class VercelSessionError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13002, file=file, original_exception=original_exception, message=message + ) + + +class VercelIdentityError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13003, file=file, original_exception=original_exception, message=message + ) + + +class VercelInvalidTeamError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13004, file=file, original_exception=original_exception, message=message + ) + + +class VercelInvalidProviderIdError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13005, file=file, original_exception=original_exception, message=message + ) + + +class VercelAPIError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13006, file=file, original_exception=original_exception, message=message + ) + + +class VercelRateLimitError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13007, file=file, original_exception=original_exception, message=message + ) + + +class VercelPlanLimitationError(VercelBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 13008, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/vercel/lib/__init__.py b/prowler/providers/vercel/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/lib/arguments/__init__.py b/prowler/providers/vercel/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/lib/arguments/arguments.py b/prowler/providers/vercel/lib/arguments/arguments.py new file mode 100644 index 0000000000..df179ff4d8 --- /dev/null +++ b/prowler/providers/vercel/lib/arguments/arguments.py @@ -0,0 +1,18 @@ +def init_parser(self): + """Init the Vercel provider CLI parser.""" + vercel_parser = self.subparsers.add_parser( + "vercel", + parents=[self.common_providers_parser], + help="Vercel Provider", + ) + + # Scope + scope_group = vercel_parser.add_argument_group("Scope") + scope_group.add_argument( + "--project", + "--projects", + nargs="*", + default=None, + metavar="PROJECT", + help="Filter scan to specific Vercel project names or IDs.", + ) diff --git a/prowler/providers/vercel/lib/mutelist/__init__.py b/prowler/providers/vercel/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/lib/mutelist/mutelist.py b/prowler/providers/vercel/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..38095a69d8 --- /dev/null +++ b/prowler/providers/vercel/lib/mutelist/mutelist.py @@ -0,0 +1,20 @@ +from prowler.lib.check.models import CheckReportVercel +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class VercelMutelist(Mutelist): + """Vercel-specific mutelist helper.""" + + def is_finding_muted( + self, + finding: CheckReportVercel, + team_id: str, + ) -> bool: + return self.is_muted( + team_id, + finding.check_metadata.CheckID, + "global", # Vercel is a global service + finding.resource_id or finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/vercel/lib/service/__init__.py b/prowler/providers/vercel/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/lib/service/service.py b/prowler/providers/vercel/lib/service/service.py new file mode 100644 index 0000000000..aaf4d2625c --- /dev/null +++ b/prowler/providers/vercel/lib/service/service.py @@ -0,0 +1,177 @@ +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +import requests + +from prowler.lib.logger import logger +from prowler.providers.vercel.exceptions.exceptions import ( + VercelAPIError, + VercelRateLimitError, +) + +MAX_WORKERS = 10 + + +class VercelService: + """Base class for Vercel services to share provider context and HTTP client.""" + + def __init__(self, service: str, provider): + self.provider = provider + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() if not service.islower() else service + + # Set up HTTP session with Bearer token + self._http_session = requests.Session() + self._http_session.headers.update( + { + "Authorization": f"Bearer {provider.session.token}", + "Content-Type": "application/json", + } + ) + self._base_url = provider.session.base_url + self._team_id = provider.session.team_id + + # Thread pool for parallel API calls + self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) + + @property + def _all_team_ids(self) -> list[str]: + """Return team IDs to scan: explicit team_id, or all auto-discovered teams.""" + if self._team_id: + return [self._team_id] + return [t.id for t in self.provider.identity.teams] + + def _get(self, path: str, params: dict = None) -> dict: + """Make a rate-limit-aware GET request to the Vercel API. + + Args: + path: API path (e.g., "/v9/projects"). + params: Query parameters. + + Returns: + Parsed JSON response as dict. + + Raises: + VercelRateLimitError: If rate limited after retries. + VercelAPIError: If the API returns an error. + """ + if params is None: + params = {} + + # Append teamId if operating in team scope + if self._team_id and "teamId" not in params: + params["teamId"] = self._team_id + + url = f"{self._base_url}{path}" + max_retries = self.audit_config.get("max_retries", 3) + + for attempt in range(max_retries + 1): + try: + response = self._http_session.get(url, params=params, timeout=30) + + if response.status_code == 429: + retry_after = int(response.headers.get("Retry-After", 5)) + if attempt < max_retries: + logger.warning( + f"{self.service} - Rate limited, retrying after {retry_after}s (attempt {attempt + 1}/{max_retries})" + ) + time.sleep(retry_after) + continue + raise VercelRateLimitError( + file=__file__, + message=f"Rate limited on {path} after {max_retries} retries.", + ) + + if response.status_code == 403: + # Plan limitation or permission error — return None for graceful handling + logger.warning( + f"{self.service} - Access denied for {path} (403). " + "This may be a plan limitation." + ) + return None + + response.raise_for_status() + return response.json() + + except VercelRateLimitError: + raise + except requests.exceptions.HTTPError as error: + raise VercelAPIError( + file=__file__, + original_exception=error, + message=f"HTTP error on {path}: {error}", + ) + except requests.exceptions.RequestException as error: + if attempt < max_retries: + logger.warning( + f"{self.service} - Request error on {path}, retrying (attempt {attempt + 1}/{max_retries}): {error}" + ) + time.sleep(2**attempt) + continue + raise VercelAPIError( + file=__file__, + original_exception=error, + message=f"Request failed on {path} after {max_retries} retries: {error}", + ) + + return {} + + def _paginate(self, path: str, key: str, params: dict = None) -> list: + """Paginate through a Vercel API list endpoint. + + Vercel uses cursor-based pagination with a `pagination.next` field. + + Args: + path: API path. + key: JSON key containing the list of items. + params: Additional query parameters. + + Returns: + Combined list of all items across pages. + """ + if params is None: + params = {} + + params["limit"] = params.get("limit", 100) + all_items = [] + + while True: + data = self._get(path, params) + if data is None: + break + + items = data.get(key, []) + all_items.extend(items) + + # Check for next page cursor + pagination = data.get("pagination", {}) + next_cursor = pagination.get("next") + if not next_cursor: + break + + params["until"] = next_cursor + + return all_items + + def __threading_call__(self, call, iterator): + """Execute a function across multiple items using threading.""" + items = list(iterator) if not isinstance(iterator, list) else iterator + + futures = {self.thread_pool.submit(call, item): item for item in items} + results = [] + + for future in as_completed(futures): + try: + result = future.result() + if result is not None: + results.append(result) + except Exception as error: + item = futures[future] + item_id = getattr(item, "id", str(item)) + logger.error( + f"{self.service} - Threading error processing {item_id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return results diff --git a/prowler/providers/vercel/models.py b/prowler/providers/vercel/models.py new file mode 100644 index 0000000000..5f0e207f19 --- /dev/null +++ b/prowler/providers/vercel/models.py @@ -0,0 +1,52 @@ +from typing import Any, Optional + +from pydantic import BaseModel, Field + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class VercelSession(BaseModel): + """Vercel API session information.""" + + token: str + team_id: Optional[str] = None + base_url: str = "https://api.vercel.com" + http_session: Any = Field(default=None, exclude=True) + + +class VercelTeamInfo(BaseModel): + """Vercel team metadata.""" + + id: str + name: str + slug: str + + +class VercelIdentityInfo(BaseModel): + """Vercel identity and scoping information.""" + + user_id: Optional[str] = None + username: Optional[str] = None + email: Optional[str] = None + team: Optional[VercelTeamInfo] = None + teams: list[VercelTeamInfo] = Field(default_factory=list) + + +class VercelOutputOptions(ProviderOutputOptions): + """Customize output filenames for Vercel scans.""" + + def __init__(self, arguments, bulk_checks_metadata, identity: VercelIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + account_fragment = ( + identity.team.slug if identity.team else identity.username or "vercel" + ) + self.output_filename = ( + f"prowler-output-{account_fragment}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/vercel/services/__init__.py b/prowler/providers/vercel/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/authentication/__init__.py b/prowler/providers/vercel/services/authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/authentication/authentication_client.py b/prowler/providers/vercel/services/authentication/authentication_client.py new file mode 100644 index 0000000000..b5074a9f94 --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.services.authentication.authentication_service import ( + Authentication, +) + +authentication_client = Authentication(Provider.get_global_provider()) diff --git a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/__init__.py b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json new file mode 100644 index 0000000000..d94e12f0b9 --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "authentication_no_stale_tokens", + "CheckTitle": "Vercel API tokens are not stale or unused for over 90 days", + "CheckType": [], + "ServiceName": "authentication", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "**Vercel API tokens** are assessed for **staleness** by checking whether each token has been active within the last 90 days. Stale tokens that remain unused for extended periods represent unnecessary access credentials that increase the attack surface. Tokens with no recorded activity are also flagged.", + "Risk": "Stale tokens that have not been used for over **90 days** may belong to decommissioned integrations, former team members, or forgotten automation. These tokens remain **valid** and could be compromised or misused without detection, as their inactivity makes suspicious usage harder to notice in access logs.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/rest-api#authentication" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to Account Settings > Tokens\n3. Review the last active date for each token\n4. Revoke or delete tokens that have not been used in over 90 days\n5. Contact token owners to confirm whether the token is still needed\n6. Implement a regular token review process (e.g., quarterly)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Regularly audit API tokens and revoke any that have not been used within 90 days. Implement a token lifecycle management process that includes periodic reviews, automatic expiration dates, and documentation of each token's purpose and owner.", + "Url": "https://hub.prowler.com/checks/vercel/authentication_no_stale_tokens" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "authentication_token_not_expired" + ], + "Notes": "The stale threshold is configurable via ``stale_token_threshold_days`` in audit_config (default: 90 days). Tokens with no recorded activity (active_at is None) are considered stale." +} diff --git a/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.py b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.py new file mode 100644 index 0000000000..5b958ce39e --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens.py @@ -0,0 +1,69 @@ +from datetime import datetime, timedelta, timezone +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.authentication.authentication_client import ( + authentication_client, +) + + +class authentication_no_stale_tokens(Check): + """Check if API tokens have been used recently. + + This class verifies whether each Vercel API token has been active within + the configured threshold (default: 90 days). Stale tokens that remain + unused pose a security risk as they may have been forgotten or belong + to former team members. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Stale Token check. + + Iterates over all tokens and checks if each token has been active + within the configured threshold. The threshold is configurable via + ``stale_token_threshold_days`` in audit_config (default: 90 days). + + Returns: + List[CheckReportVercel]: A list of reports for each token. + """ + findings = [] + now = datetime.now(timezone.utc) + stale_threshold_days = authentication_client.audit_config.get( + "stale_token_threshold_days", 90 + ) + stale_cutoff = now - timedelta(days=stale_threshold_days) + + for token in authentication_client.tokens.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=token, + resource_name=token.name, + resource_id=token.id, + ) + + if token.active_at is None: + report.status = "FAIL" + report.status_extended = ( + f"Token '{token.name}' ({token.id}) has no recorded activity " + f"and is considered stale." + ) + elif token.active_at < stale_cutoff: + days_inactive = (now - token.active_at).days + report.status = "FAIL" + report.status_extended = ( + f"Token '{token.name}' ({token.id}) has not been used for " + f"{days_inactive} days (last active: " + f"{token.active_at.strftime('%Y-%m-%d %H:%M UTC')}). " + f"Threshold is {stale_threshold_days} days." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Token '{token.name}' ({token.id}) was last active on " + f"{token.active_at.strftime('%Y-%m-%d %H:%M UTC')} " + f"(within the last {stale_threshold_days} days)." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/authentication/authentication_service.py b/prowler/providers/vercel/services/authentication/authentication_service.py new file mode 100644 index 0000000000..ef1ed5b2c7 --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_service.py @@ -0,0 +1,99 @@ +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.vercel.lib.service.service import VercelService + + +class Authentication(VercelService): + """Retrieve Vercel API token metadata for hygiene checks.""" + + def __init__(self, provider): + super().__init__("Authentication", provider) + self.tokens: dict[str, VercelAuthToken] = {} + self._list_tokens() + + def _list_tokens(self): + """List all API tokens for the authenticated user and their teams.""" + # Always fetch personal tokens (no teamId filter) + self._fetch_tokens_for_scope(team_id=None) + + # Also fetch tokens scoped to each team + for tid in self._all_team_ids: + self._fetch_tokens_for_scope(team_id=tid) + + logger.info(f"Authentication - Found {len(self.tokens)} token(s)") + + def _fetch_tokens_for_scope(self, team_id: str = None): + """Fetch tokens for a specific scope (personal or team). + + Args: + team_id: Team ID to fetch tokens for. None for personal tokens. + """ + try: + # Always set teamId key explicitly — _get won't auto-inject when key + # is present, and requests skips None values from query params. + params = {"teamId": team_id} + data = self._get("/v5/user/tokens", params=params) + if not data: + return + + tokens = data.get("tokens", []) + + for token in tokens: + token_id = token.get("id", "") + if not token_id or token_id in self.tokens: + continue + + active_at = None + if token.get("activeAt"): + active_at = datetime.fromtimestamp( + token["activeAt"] / 1000, tz=timezone.utc + ) + + created_at = None + if token.get("createdAt"): + created_at = datetime.fromtimestamp( + token["createdAt"] / 1000, tz=timezone.utc + ) + + expires_at = None + if token.get("expiresAt"): + expires_at = datetime.fromtimestamp( + token["expiresAt"] / 1000, tz=timezone.utc + ) + + self.tokens[token_id] = VercelAuthToken( + id=token_id, + name=token.get("name", "Unnamed Token"), + type=token.get("type"), + active_at=active_at, + created_at=created_at, + expires_at=expires_at, + scopes=token.get("scopes", []), + origin=token.get("origin"), + team_id=token.get("teamId") or team_id, + ) + + except Exception as error: + scope = f"team {team_id}" if team_id else "personal" + logger.error( + f"Authentication - Error listing tokens for {scope}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class VercelAuthToken(BaseModel): + """Vercel API token representation.""" + + id: str + name: str + type: Optional[str] = None + active_at: Optional[datetime] = None + created_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + scopes: list[dict] = Field(default_factory=list) + origin: Optional[str] = None + team_id: Optional[str] = None diff --git a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/__init__.py b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json new file mode 100644 index 0000000000..dca48e73bf --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "authentication_token_not_expired", + "CheckTitle": "Vercel API tokens have not expired", + "CheckType": [], + "ServiceName": "authentication", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel API tokens** are assessed for **expiration status** to identify expired tokens or those about to expire within a configurable threshold (default: 7 days). Tokens about to expire are flagged proactively so they can be rotated before causing disruptions. Tokens without an expiration date are considered valid.", + "Risk": "Expired tokens indicate poor **token lifecycle management**. Tokens about to expire risk **imminent service disruption** if not rotated in time. Integrations or **CI/CD pipelines** relying on expired or soon-to-expire tokens will fail silently.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/rest-api#authentication" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to Account Settings > Tokens\n3. Identify any expired tokens\n4. Delete expired tokens that are no longer needed\n5. Create new tokens with appropriate expiration dates to replace expired ones\n6. Update any integrations or CI/CD pipelines that used the expired tokens", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove expired tokens and create new ones with appropriate expiration dates. Implement a token rotation schedule to ensure tokens are refreshed before they expire. Update all integrations and automation that depend on the replaced tokens.", + "Url": "https://hub.prowler.com/checks/vercel/authentication_token_not_expired" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "authentication_no_stale_tokens" + ], + "Notes": "Tokens without an expiration date (expires_at is None) are treated as valid since they have no defined expiry. The days_to_expire_threshold is configurable via audit_config (default: 7 days). Tokens expiring within the threshold are reported with medium severity; already expired tokens are reported with high severity." +} diff --git a/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.py b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.py new file mode 100644 index 0000000000..b3e9c3ec9e --- /dev/null +++ b/prowler/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired.py @@ -0,0 +1,75 @@ +from datetime import datetime, timezone +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel, Severity +from prowler.providers.vercel.services.authentication.authentication_client import ( + authentication_client, +) + + +class authentication_token_not_expired(Check): + """Check if API tokens have not expired or are about to expire. + + This class verifies whether each Vercel API token is still valid by + checking its expiration date against the current time. Tokens expiring + within a configurable threshold (default: 7 days) are flagged as + about to expire with medium severity. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Token Expiration check. + + Iterates over all tokens and checks if each token has expired or + is about to expire soon. The threshold is configurable via + ``days_to_expire_threshold`` in audit_config (default: 7 days). + Tokens without an expiration date are considered valid (no expiry set). + + Returns: + List[CheckReportVercel]: A list of reports for each token. + """ + findings = [] + now = datetime.now(timezone.utc) + days_to_expire_threshold = authentication_client.audit_config.get( + "days_to_expire_threshold", 7 + ) + for token in authentication_client.tokens.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=token, + resource_name=token.name, + resource_id=token.id, + ) + + if token.expires_at is None: + report.status = "PASS" + report.status_extended = ( + f"Token '{token.name}' ({token.id}) does not have an expiration " + f"date set and is currently valid." + ) + elif token.expires_at <= now: + report.status = "FAIL" + report.check_metadata.Severity = Severity.high + report.status_extended = ( + f"Token '{token.name}' ({token.id}) has expired " + f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + else: + days_left = (token.expires_at - now).days + if days_left <= days_to_expire_threshold: + report.status = "FAIL" + report.check_metadata.Severity = Severity.medium + report.status_extended = ( + f"Token '{token.name}' ({token.id}) is about to expire " + f"in {days_left} days " + f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Token '{token.name}' ({token.id}) is valid and expires " + f"on {token.expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/deployment/__init__.py b/prowler/providers/vercel/services/deployment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/deployment/deployment_client.py b/prowler/providers/vercel/services/deployment/deployment_client.py new file mode 100644 index 0000000000..18c240a83b --- /dev/null +++ b/prowler/providers/vercel/services/deployment/deployment_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.services.deployment.deployment_service import Deployment + +deployment_client = Deployment(Provider.get_global_provider()) diff --git a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/__init__.py b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json new file mode 100644 index 0000000000..8a7cc70d40 --- /dev/null +++ b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "deployment_production_uses_stable_target", + "CheckTitle": "Vercel production deployments originate from a stable branch", + "CheckType": [], + "ServiceName": "deployment", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "devops", + "Description": "**Vercel production deployments** are assessed for **source branch stability** by verifying they are sourced from a stable branch (`main` or `master`). Deploying to production from feature branches bypasses standard CI/CD review processes and may introduce untested or incomplete code into the production environment.", + "Risk": "Production deployments from **feature branches** may contain untested, incomplete, or unapproved code changes. This bypasses the standard **code review and merge workflow**, increasing the risk of shipping bugs, security vulnerabilities, or breaking changes to end users.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/deployments/git" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Git\n3. Ensure the Production Branch is set to 'main' or 'master'\n4. Review recent production deployments and revert any that originated from feature branches", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure the production branch to main or master and ensure all production deployments go through the standard merge workflow. Use branch protection rules in your Git provider to prevent direct pushes to the production branch.", + "Url": "https://hub.prowler.com/checks/vercel/deployment_production_uses_stable_target" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Deployments without git source information are skipped as they may be manual deployments or CLI-triggered builds." +} diff --git a/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.py b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.py new file mode 100644 index 0000000000..39b4d43904 --- /dev/null +++ b/prowler/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target.py @@ -0,0 +1,57 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.deployment.deployment_client import ( + deployment_client, +) + + +class deployment_production_uses_stable_target(Check): + """Check if production deployments are sourced from a stable branch. + + This class verifies whether each Vercel production deployment originates + from a configured stable branch rather than a feature branch. The list of + stable branches is configurable via audit_config key ``stable_branches`` + (default: ``["main", "master"]``). + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Production Deployment Stable Target check. + + Iterates over all deployments, filters for production targets with + git source information, and checks if the branch is main or master. + + Returns: + List[CheckReportVercel]: A list of reports for each production deployment. + """ + findings = [] + for deployment in deployment_client.deployments.values(): + if deployment.target != "production": + continue + + if not deployment.git_source: + continue + + report = CheckReportVercel(metadata=self.metadata(), resource=deployment) + + stable_branches = deployment_client.audit_config.get( + "stable_branches", ["main", "master"] + ) + branch = deployment.git_source.get("branch") or "" + if branch in stable_branches: + report.status = "PASS" + report.status_extended = ( + f"Production deployment {deployment.name} ({deployment.id}) " + f"is sourced from stable branch '{branch}'." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Production deployment {deployment.name} ({deployment.id}) " + f"is sourced from branch '{branch}' instead of a " + f"configured stable branch ({', '.join(stable_branches)})." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/deployment/deployment_service.py b/prowler/providers/vercel/services/deployment/deployment_service.py new file mode 100644 index 0000000000..ec271086c9 --- /dev/null +++ b/prowler/providers/vercel/services/deployment/deployment_service.py @@ -0,0 +1,103 @@ +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.vercel.lib.service.service import VercelService + + +class Deployment(VercelService): + """Retrieve recent Vercel deployments.""" + + def __init__(self, provider): + super().__init__("Deployment", provider) + self.deployments: dict[str, VercelDeployment] = {} + self._list_deployments() + + def _list_deployments(self): + """List recent deployments across all projects.""" + try: + params = {"limit": 100} + # Fetch only recent deployments (first page is sufficient for security checks) + raw_deployments = self._paginate("/v6/deployments", "deployments", params) + + seen_ids: set[str] = set() + filter_projects = self.provider.filter_projects + + for dep in raw_deployments: + dep_id = dep.get("uid", dep.get("id", "")) + if not dep_id or dep_id in seen_ids: + continue + seen_ids.add(dep_id) + + project_id = dep.get("projectId", "") + + # Apply project filter if specified + if filter_projects and project_id not in filter_projects: + project_name = dep.get("name", "") + if project_name not in filter_projects: + continue + + created_at = None + if dep.get("createdAt"): + created_at = datetime.fromtimestamp( + dep["createdAt"] / 1000, tz=timezone.utc + ) + + ready_at = None + if dep.get("ready"): + ready_at = datetime.fromtimestamp( + dep["ready"] / 1000, tz=timezone.utc + ) + + git_source = None + meta = dep.get("meta", {}) or {} + if meta.get("githubCommitSha") or meta.get("gitlabCommitSha"): + git_source = { + "commit_sha": meta.get("githubCommitSha") + or meta.get("gitlabCommitSha"), + "branch": meta.get("githubCommitRef") + or meta.get("gitlabCommitRef"), + "repo": meta.get("githubRepo") or meta.get("gitlabRepo"), + } + + self.deployments[dep_id] = VercelDeployment( + id=dep_id, + name=dep.get("name", ""), + url=dep.get("url", ""), + state=dep.get("state", dep.get("readyState", "")), + target=dep.get("target"), + created_at=created_at, + ready_at=ready_at, + project_id=project_id, + project_name=dep.get("name", ""), + team_id=dep.get("teamId") or self.provider.session.team_id, + git_source=git_source, + deployment_protection=dep.get("deploymentProtection"), + ) + + logger.info(f"Deployment - Found {len(self.deployments)} deployment(s)") + + except Exception as error: + logger.error( + f"Deployment - Error listing deployments: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class VercelDeployment(BaseModel): + """Vercel deployment representation.""" + + id: str + name: str + url: str = "" + state: str = "" + target: Optional[str] = None # "production" | "preview" + created_at: Optional[datetime] = None + ready_at: Optional[datetime] = None + project_id: Optional[str] = None + project_name: Optional[str] = None + team_id: Optional[str] = None + git_source: Optional[dict] = None + deployment_protection: Optional[dict] = None diff --git a/prowler/providers/vercel/services/domain/__init__.py b/prowler/providers/vercel/services/domain/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/domain/domain_client.py b/prowler/providers/vercel/services/domain/domain_client.py new file mode 100644 index 0000000000..30479da773 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.services.domain.domain_service import Domain + +domain_client = Domain(Provider.get_global_provider()) diff --git a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/__init__.py b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json new file mode 100644 index 0000000000..f9104ee960 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "domain_dns_properly_configured", + "CheckTitle": "Vercel domain DNS records are properly configured", + "CheckType": [], + "ServiceName": "domain", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Vercel domains** are assessed for **DNS configuration** to verify records properly point to Vercel's infrastructure. Misconfigured DNS can result in domains that fail to serve content, SSL certificate provisioning failures, and degraded user experience.", + "Risk": "**Misconfigured DNS records** can cause the domain to be unreachable, preventing users from accessing the application. It can also prevent **SSL certificate provisioning**, resulting in browser security warnings. Stale DNS configurations may point to decommissioned infrastructure, creating a risk of **subdomain takeover**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/projects/domains" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Domains\n3. Review the DNS configuration status for each domain\n4. Update DNS records at your domain registrar to match the values shown in the Vercel dashboard\n5. Wait for DNS propagation (typically 24-48 hours)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Update DNS records at your domain registrar to correctly point to Vercel. Use a CNAME record for subdomains or an A record for apex domains. Verify the configuration in the Vercel dashboard after making changes.", + "Url": "https://hub.prowler.com/checks/vercel/domain_dns_properly_configured" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "domain_verified", + "domain_ssl_certificate_valid" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.py b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.py new file mode 100644 index 0000000000..1e3f91c394 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured.py @@ -0,0 +1,45 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.domain.domain_client import domain_client + + +class domain_dns_properly_configured(Check): + """Check if domains have DNS properly configured. + + This class verifies whether each Vercel domain has its DNS records + properly configured to point to Vercel's infrastructure. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Domain DNS Configuration check. + + Iterates over all domains and checks if DNS is properly configured. + + Returns: + List[CheckReportVercel]: A list of reports for each domain. + """ + findings = [] + for domain in domain_client.domains.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=domain, + resource_name=domain.name, + resource_id=domain.id or domain.name, + ) + + if domain.configured: + report.status = "PASS" + report.status_extended = ( + f"Domain {domain.name} has DNS properly configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Domain {domain.name} does not have DNS properly configured. " + f"The domain may not be resolving to Vercel's infrastructure." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/domain/domain_service.py b/prowler/providers/vercel/services/domain/domain_service.py new file mode 100644 index 0000000000..9b3dba644a --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_service.py @@ -0,0 +1,124 @@ +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.vercel.lib.service.service import VercelService + + +class Domain(VercelService): + """Retrieve Vercel domains with DNS and SSL information.""" + + def __init__(self, provider): + super().__init__("Domain", provider) + self.domains: dict[str, VercelDomain] = {} + self._list_domains() + self.__threading_call__(self._fetch_dns_records, list(self.domains.values())) + self.__threading_call__( + self._fetch_ssl_certificate, list(self.domains.values()) + ) + + def _list_domains(self): + """List all domains.""" + try: + raw_domains = self._paginate("/v5/domains", "domains") + + seen_names: set[str] = set() + + for domain in raw_domains: + domain_name = domain.get("name", "") + if not domain_name or domain_name in seen_names: + continue + seen_names.add(domain_name) + + self.domains[domain_name] = VercelDomain( + name=domain_name, + id=domain.get("id", domain_name), + apex_name=domain.get("apexName"), + verified=domain.get("verified", False), + configured=( + domain.get("configured", False) + if "configured" in domain + else domain.get("verified", False) + ), + redirect=domain.get("redirect"), + team_id=self.provider.session.team_id, + ) + + logger.info(f"Domain - Found {len(self.domains)} domain(s)") + + except Exception as error: + logger.error( + f"Domain - Error listing domains: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_dns_records(self, domain: "VercelDomain"): + """Fetch DNS records for a single domain.""" + try: + data = self._get(f"/v4/domains/{domain.name}/records") + if data and "records" in data: + domain.dns_records = data["records"] + logger.debug( + f"Domain - Fetched {len(domain.dns_records)} DNS records for {domain.name}" + ) + except Exception as error: + logger.error( + f"Domain - Error fetching DNS records for {domain.name}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_ssl_certificate(self, domain: "VercelDomain"): + """Fetch SSL certificate for a domain via the certs endpoint.""" + try: + data = self._get(f"/v8/certs/{domain.name}") + if data: + expires_at_ms = data.get("expiresAt") + created_at_ms = data.get("createdAt") + domain.ssl_certificate = VercelSSLCertificate( + id=data.get("id", ""), + created_at=( + datetime.fromtimestamp(created_at_ms / 1000, tz=timezone.utc) + if created_at_ms + else None + ), + expires_at=( + datetime.fromtimestamp(expires_at_ms / 1000, tz=timezone.utc) + if expires_at_ms + else None + ), + auto_renew=data.get("autoRenew", False), + cns=data.get("cns", []), + ) + logger.debug(f"Domain - Fetched SSL certificate for {domain.name}") + except Exception as error: + logger.error( + f"Domain - Error fetching SSL certificate for {domain.name}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class VercelSSLCertificate(BaseModel): + """Vercel SSL certificate representation.""" + + id: str = "" + created_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + auto_renew: bool = False + cns: list[str] = Field(default_factory=list) + + +class VercelDomain(BaseModel): + """Vercel domain representation.""" + + name: str + id: str = "" + apex_name: Optional[str] = None + verified: bool = False + configured: bool = False + ssl_certificate: Optional[VercelSSLCertificate] = None + redirect: Optional[str] = None + dns_records: list[dict] = Field(default_factory=list) + team_id: Optional[str] = None + project_id: Optional[str] = None diff --git a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/__init__.py b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json new file mode 100644 index 0000000000..f15892df61 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "domain_ssl_certificate_valid", + "CheckTitle": "Vercel domains have a valid, non-expired SSL certificate", + "CheckType": [], + "ServiceName": "domain", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Vercel domains** are assessed for **SSL certificate validity** including provisioning, expiration, and upcoming expiry. Vercel automatically provisions and renews SSL certificates for properly configured domains. A missing, expired, or soon-to-expire certificate indicates a configuration issue that may leave traffic unencrypted.", + "Risk": "Without an **SSL certificate**, traffic between users and the domain is transmitted in **plain text**. This exposes sensitive data such as authentication tokens, form submissions, and personal information to interception via **man-in-the-middle attacks**. Search engines also penalize non-HTTPS sites, reducing visibility.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/encryption" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Domains\n3. Verify the domain's DNS records point to Vercel correctly\n4. Vercel will automatically provision an SSL certificate once DNS is properly configured\n5. If issues persist, remove and re-add the domain", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure domain DNS records are properly configured to point to Vercel. Once DNS is validated, Vercel automatically provisions and renews SSL/TLS certificates. Check the domain configuration in the Vercel dashboard if the certificate is not being issued.", + "Url": "https://hub.prowler.com/checks/vercel/domain_ssl_certificate_valid" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "domain_verified", + "domain_dns_properly_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.py b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.py new file mode 100644 index 0000000000..f4e61e6675 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid.py @@ -0,0 +1,78 @@ +from datetime import datetime, timezone +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel, Severity +from prowler.providers.vercel.services.domain.domain_client import domain_client + + +class domain_ssl_certificate_valid(Check): + """Check if domains have a valid, non-expired SSL certificate. + + This class verifies whether each Vercel domain has an SSL certificate + that is provisioned, not expired, and not about to expire. The + expiration threshold is configurable via ``days_to_expire_threshold`` + in audit_config (default: 7 days). + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Domain SSL Certificate check. + + Iterates over all domains and checks SSL certificate presence and + expiration status. + + Returns: + List[CheckReportVercel]: A list of reports for each domain. + """ + findings = [] + now = datetime.now(timezone.utc) + days_to_expire_threshold = domain_client.audit_config.get( + "days_to_expire_threshold", 7 + ) + + for domain in domain_client.domains.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=domain, + resource_name=domain.name, + resource_id=domain.id or domain.name, + ) + + if domain.ssl_certificate is None: + report.status = "FAIL" + report.check_metadata.Severity = Severity.high + report.status_extended = f"Domain {domain.name} does not have an SSL certificate provisioned." + elif ( + domain.ssl_certificate.expires_at is not None + and domain.ssl_certificate.expires_at <= now + ): + report.status = "FAIL" + report.check_metadata.Severity = Severity.critical + report.status_extended = ( + f"Domain {domain.name} has an SSL certificate that expired " + f"on {domain.ssl_certificate.expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + elif domain.ssl_certificate.expires_at is not None: + days_left = (domain.ssl_certificate.expires_at - now).days + if days_left <= days_to_expire_threshold: + report.status = "FAIL" + report.check_metadata.Severity = Severity.high + report.status_extended = ( + f"Domain {domain.name} has an SSL certificate expiring " + f"in {days_left} days " + f"on {domain.ssl_certificate.expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Domain {domain.name} has a valid SSL certificate expiring " + f"on {domain.ssl_certificate.expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Domain {domain.name} has an SSL certificate provisioned." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/domain/domain_verified/__init__.py b/prowler/providers/vercel/services/domain/domain_verified/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json new file mode 100644 index 0000000000..f520fb00d8 --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "domain_verified", + "CheckTitle": "Vercel domains are verified", + "CheckType": [], + "ServiceName": "domain", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Vercel domains** are assessed for **ownership verification** status. Unverified domains may not serve traffic correctly and could indicate a pending or incomplete domain setup. Domain verification confirms that the domain owner has authorized Vercel to manage the domain.", + "Risk": "**Unverified domains** may fail to resolve or serve content, causing **downtime** for users. An unverified domain could also indicate a stale or orphaned configuration, or a domain that was added but never properly transferred, creating potential for **domain takeover** if the ownership verification is left incomplete.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/projects/domains" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Domains\n3. For any unverified domain, follow the verification steps shown\n4. Add the required DNS records (CNAME or A record) at your domain registrar\n5. Wait for DNS propagation and verify the domain", + "Terraform": "" + }, + "Recommendation": { + "Text": "Complete domain verification by configuring the required DNS records at your domain registrar. Remove any domains that are no longer needed to reduce the attack surface. Regularly audit domain configurations to ensure all domains remain verified.", + "Url": "https://hub.prowler.com/checks/vercel/domain_verified" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "domain_dns_properly_configured", + "domain_ssl_certificate_valid" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/domain/domain_verified/domain_verified.py b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.py new file mode 100644 index 0000000000..b6589b873e --- /dev/null +++ b/prowler/providers/vercel/services/domain/domain_verified/domain_verified.py @@ -0,0 +1,44 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.domain.domain_client import domain_client + + +class domain_verified(Check): + """Check if domains have been verified by Vercel. + + This class verifies whether each Vercel domain has passed ownership + verification. Unverified domains may not function correctly and could + indicate domain misconfiguration or hijacking attempts. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Domain Verified check. + + Iterates over all domains and checks if each is verified. + + Returns: + List[CheckReportVercel]: A list of reports for each domain. + """ + findings = [] + for domain in domain_client.domains.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=domain, + resource_name=domain.name, + resource_id=domain.id or domain.name, + ) + + if domain.verified: + report.status = "PASS" + report.status_extended = f"Domain {domain.name} is verified." + else: + report.status = "FAIL" + report.status_extended = ( + f"Domain {domain.name} is not verified. " + f"The domain may not be serving traffic correctly." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/__init__.py b/prowler/providers/vercel/services/project/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/__init__.py b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json new file mode 100644 index 0000000000..bf7adc2832 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "project_auto_expose_system_env_disabled", + "CheckTitle": "Vercel project has automatic exposure of system environment variables disabled", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **automatic system environment variable exposure** (`VERCEL_URL`, `VERCEL_ENV`, `VERCEL_GIT_COMMIT_SHA`). When enabled, these variables are injected into every deployment and may be accessible in client-side JavaScript bundles if not handled carefully, leaking internal infrastructure details.", + "Risk": "Automatically exposed **system environment variables** can reveal deployment URLs, Git metadata, environment names, and other internal details. If these values are inadvertently included in **client-side bundles**, attackers can use them to map infrastructure, identify staging environments, or craft targeted attacks against specific deployment instances.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/projects/environment-variables/system-environment-variables" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Environment Variables\n3. Locate the 'Automatically expose System Environment Variables' toggle\n4. Disable the toggle\n5. Manually add only the specific system variables your application needs", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable automatic exposure of system environment variables and explicitly define only the variables required by your application. This follows the principle of least privilege and reduces the risk of leaking internal infrastructure details through client-side code.", + "Url": "https://hub.prowler.com/checks/vercel/project_auto_expose_system_env_disabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.py b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.py new file mode 100644 index 0000000000..e2d12ceab9 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled.py @@ -0,0 +1,42 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + + +class project_auto_expose_system_env_disabled(Check): + """Check if automatic exposure of system environment variables is disabled. + + This class verifies whether each Vercel project has the automatic exposure + of system environment variables disabled to prevent information leakage. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Project Auto Expose System Env check. + + Iterates over all projects and checks if automatic exposure of system + environment variables is disabled. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + if not project.auto_expose_system_envs: + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} does not automatically expose " + f"system environment variables to the build process." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} automatically exposes system " + f"environment variables to the build process." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_client.py b/prowler/providers/vercel/services/project/project_client.py new file mode 100644 index 0000000000..571180d3e7 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.services.project.project_service import Project + +project_client = Project(Provider.get_global_provider()) diff --git a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json new file mode 100644 index 0000000000..c704b42784 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "project_deployment_protection_enabled", + "CheckTitle": "Vercel project has deployment protection enabled on preview deployments", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **deployment protection** configuration, which restricts access to preview deployments by requiring authentication before visitors can view them. When disabled, anyone with the preview URL can access in-progress or staging versions of the application, potentially exposing unreleased features, debug information, or internal endpoints.", + "Risk": "Without **deployment protection** on preview deployments, any person who obtains or guesses a preview URL can view **unreleased application code**, test data, or internal API endpoints. This increases the attack surface and may leak sensitive business logic or credentials embedded in preview builds.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/deployment-protection" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Scroll to Deployment Protection\n4. Under Preview deployments, select 'Standard Protection' or 'Vercel Authentication'\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable deployment protection on preview deployments to require authentication before visitors can access preview URLs. Use 'Standard Protection' for Vercel Authentication or configure trusted IP ranges for more granular control.", + "Url": "https://hub.prowler.com/checks/vercel/project_deployment_protection_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "project_production_deployment_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.py b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.py new file mode 100644 index 0000000000..b9966b27db --- /dev/null +++ b/prowler/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled.py @@ -0,0 +1,45 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + + +class project_deployment_protection_enabled(Check): + """Check if deployment protection is enabled on preview deployments. + + This class verifies whether each Vercel project has deployment protection + configured for preview deployments to prevent unauthorized access. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Project Deployment Protection check. + + Iterates over all projects and checks if deployment protection is enabled + on preview deployments. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + if ( + project.deployment_protection is not None + and project.deployment_protection.level != "none" + ): + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has deployment protection enabled " + f"with level '{project.deployment_protection.level}' on preview deployments." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} does not have deployment protection " + f"enabled on preview deployments." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_directory_listing_disabled/__init__.py b/prowler/providers/vercel/services/project/project_directory_listing_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json new file mode 100644 index 0000000000..b9de49aa0e --- /dev/null +++ b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "project_directory_listing_disabled", + "CheckTitle": "Vercel project has directory listing disabled", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **directory listing** configuration. When enabled, this feature allows visitors to browse the file structure of a deployment when no index file is present in a directory, potentially exposing source files, configuration files, and other assets that should not be publicly accessible.", + "Risk": "Enabled **directory listing** allows attackers to enumerate the file structure of the deployment, potentially discovering backup files, configuration files, source maps, or other **sensitive assets**. This information disclosure can be leveraged to identify attack vectors or access files that were not intended to be public.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/projects/project-configuration" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Locate the 'Directory Listing' option\n4. Ensure it is disabled\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable directory listing to prevent visitors from browsing the file structure of your deployments. Ensure that all directories either contain an index file or return a 404 response when accessed directly.", + "Url": "https://hub.prowler.com/checks/vercel/project_directory_listing_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.py b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.py new file mode 100644 index 0000000000..57405fe43e --- /dev/null +++ b/prowler/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled.py @@ -0,0 +1,40 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + + +class project_directory_listing_disabled(Check): + """Check if directory listing is disabled for the project. + + This class verifies whether each Vercel project has directory listing + disabled to prevent exposure of the project's file structure. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Project Directory Listing check. + + Iterates over all projects and checks if directory listing is disabled. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + if not project.directory_listing: + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has directory listing disabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} has directory listing enabled, " + f"which may expose the project's file structure to visitors." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/__init__.py b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json new file mode 100644 index 0000000000..2467c1b104 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "project_environment_no_overly_broad_target", + "CheckTitle": "Vercel project has no environment variables targeting all three environments", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel project environment variables** are assessed for **overly broad targeting** by checking whether any variable targets all three environments (production, preview, development) simultaneously, which violates the principle of least privilege.", + "Risk": "Environment variables targeting **all environments** share the same values across production, preview, and development, increasing **blast radius** if credentials are compromised. Production secrets are exposed to weaker environments, making it harder to isolate and track unauthorized changes.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/environment-variables" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel project dashboard\n2. Go to Settings > Environment Variables\n3. Identify variables that target all three environments (Production, Preview, Development)\n4. Edit each variable to target only the specific environments where it is needed\n5. Create separate variables with environment-specific values where different credentials are needed per environment", + "Terraform": "" + }, + "Recommendation": { + "Text": "Follow the **principle of least privilege** for environment variable targeting.\n- Assign each variable to only the environments where it is actually needed\n- Use different credentials for production, preview, and development environments\n- Non-sensitive configuration (e.g. feature flags, public URLs) may be acceptable in multiple environments but should still be reviewed\n- Regularly audit environment variable targets to prevent scope creep", + "Url": "https://hub.prowler.com/checks/vercel/project_environment_no_overly_broad_target" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [ + "project_environment_production_vars_not_in_preview" + ], + "Notes": "This check flags any variable targeting all three environments regardless of its type. Even non-sensitive configuration shared across all environments may indicate a lack of environment-specific configuration management." +} diff --git a/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.py b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.py new file mode 100644 index 0000000000..d62d88d965 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target.py @@ -0,0 +1,54 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + +ALL_ENVIRONMENTS = {"production", "preview", "development"} + + +class project_environment_no_overly_broad_target(Check): + """Check that no environment variables target all three environments simultaneously. + + This class verifies that environment variables are not configured to target + production, preview, and development environments at the same time, which + violates the principle of least privilege and may expose production secrets + to development and preview contexts. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the no-overly-broad-target check. + + Iterates over all projects and inspects each environment variable, + flagging any that target all three environments (production, preview, + and development) simultaneously. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + broad_keys = [] + for env_var in project.environment_variables: + targets = {t.lower() for t in env_var.target} + if ALL_ENVIRONMENTS.issubset(targets): + broad_keys.append(env_var.key) + + if broad_keys: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} has {len(broad_keys)} environment " + f"variable(s) targeting all three environments: " + f"{', '.join(broad_keys)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has no environment variables targeting " + f"all three environments simultaneously." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/__init__.py b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json new file mode 100644 index 0000000000..f8d5621914 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "project_environment_no_secrets_in_plain_type", + "CheckTitle": "Vercel project has no secret-like environment variables stored as plain text", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel project environment variables** are assessed for **secret exposure** by checking whether variables with secret-like name suffixes (`*_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`, `*_API_KEY`, `*_PRIVATE_KEY`) are stored using the `plain` type, which makes their values readable.", + "Risk": "Secrets stored as **plain text** environment variables are visible to all team members with project access and may appear in API responses. Plaintext secrets can be read through the Vercel dashboard or API, enabling **unauthorized modification** of connected services or disruption of integrations.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/environment-variables" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel project dashboard\n2. Go to Settings > Environment Variables\n3. Identify any variables ending in _KEY, _SECRET, _TOKEN, _PASSWORD, _API_KEY, or _PRIVATE_KEY that are stored as 'Plain'\n4. Delete the plain-text variable\n5. Re-create it using the 'Sensitive' type to ensure the value is encrypted and write-only", + "Terraform": "" + }, + "Recommendation": { + "Text": "Use the **Sensitive** type for all environment variables that contain secrets, keys, tokens, or passwords.\n- Sensitive variables are never exposed in the dashboard or API responses after creation\n- Rotate all credentials that were previously stored as plain text\n- Implement naming conventions that make it easy to identify secret variables", + "Url": "https://hub.prowler.com/checks/vercel/project_environment_no_secrets_in_plain_type" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [ + "project_environment_production_vars_not_in_preview" + ], + "Notes": "This check uses suffix-based matching on variable names (_KEY, _SECRET, _TOKEN, _PASSWORD, _API_KEY, _PRIVATE_KEY) to identify likely secrets." +} diff --git a/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.py b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.py new file mode 100644 index 0000000000..904e592575 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type.py @@ -0,0 +1,68 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + +DEFAULT_SECRET_SUFFIXES = [ + "_KEY", + "_SECRET", + "_TOKEN", + "_PASSWORD", + "_API_KEY", + "_PRIVATE_KEY", +] + + +class project_environment_no_secrets_in_plain_type(Check): + """Check that no environment variables with secret-like name suffixes are stored as plain text. + + This class verifies that environment variables whose names end with + configurable secret suffixes are not stored with the "plain" type, + which makes their values readable in the dashboard and API responses. + The suffix list is configurable via ``secret_suffixes`` in audit_config. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the no-secrets-in-plain-type check. + + Iterates over all projects and inspects each environment variable, + flagging any variable whose name ends with a known secret suffix and + is stored as "plain" type. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + secret_suffixes = project_client.audit_config.get( + "secret_suffixes", DEFAULT_SECRET_SUFFIXES + ) + # Normalize to uppercase tuples for efficient endswith matching + secret_suffixes_upper = tuple(s.upper() for s in secret_suffixes) + + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + plain_secret_keys = [] + for env_var in project.environment_variables: + upper_key = env_var.key.upper() + if upper_key.endswith(secret_suffixes_upper): + if env_var.type == "plain": + plain_secret_keys.append(env_var.key) + + if plain_secret_keys: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} has {len(plain_secret_keys)} secret-like " + f"environment variable(s) stored as plain text: " + f"{', '.join(plain_secret_keys)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has no secret-like environment variables " + f"stored as plain text." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/__init__.py b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json new file mode 100644 index 0000000000..55f468fa8a --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "project_environment_production_vars_not_in_preview", + "CheckTitle": "Vercel sensitive production environment variables do not target preview", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel project environment variables** are assessed for **environment separation** by checking whether sensitive variables (type `secret` or `encrypted`) that target the `production` environment also target `preview`, which could expose production credentials to untrusted preview builds.", + "Risk": "Preview deployments are often triggered by **pull requests**, including those from external contributors or forks. Sharing **production secrets** with preview environments can lead to credential theft. Production API keys and database credentials could be exfiltrated by malicious code in preview builds and used to modify or disrupt live services.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/environment-variables" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel project dashboard\n2. Go to Settings > Environment Variables\n3. Identify sensitive variables (type Secret or Encrypted) that target both Production and Preview\n4. Edit each variable to remove the Preview target\n5. If preview builds require credentials, create separate variables with limited-scope preview-only credentials", + "Terraform": "" + }, + "Recommendation": { + "Text": "Maintain strict **environment separation** between production and preview deployments.\n- Use dedicated, limited-scope credentials for preview environments\n- Never share production database credentials, API keys, or signing keys with preview builds\n- Enable Vercel's deployment protection features to further restrict access to preview deployments\n- Regularly audit which environment variables target multiple environments", + "Url": "https://hub.prowler.com/checks/vercel/project_environment_production_vars_not_in_preview" + } + }, + "Categories": [ + "secrets" + ], + "DependsOn": [], + "RelatedTo": [ + "project_environment_no_secrets_in_plain_type", + "project_environment_no_overly_broad_target" + ], + "Notes": "This check only inspects variables with type 'secret' or 'encrypted' since these are the ones most likely to contain actual credentials. Plain-text variables with sensitive names should be caught by the project_environment_no_secrets_in_plain_type check." +} diff --git a/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.py b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.py new file mode 100644 index 0000000000..ac84a08887 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview.py @@ -0,0 +1,55 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + +SENSITIVE_TYPES = {"secret", "encrypted"} + + +class project_environment_production_vars_not_in_preview(Check): + """Check that sensitive production environment variables do not also target preview. + + This class verifies that environment variables using "secret" or "encrypted" + types that target "production" do not simultaneously target "preview" + deployments, which could expose production credentials to untrusted code + running in preview builds from pull requests. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the production-vars-not-in-preview check. + + Iterates over all projects, inspects each environment variable with a + sensitive type (secret or encrypted), and flags any that target both + "production" and "preview" environments. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + leaking_keys = [] + for env_var in project.environment_variables: + if env_var.type in SENSITIVE_TYPES: + targets = {t.lower() for t in env_var.target} + if "production" in targets and "preview" in targets: + leaking_keys.append(env_var.key) + + if leaking_keys: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} has {len(leaking_keys)} sensitive " + f"production environment variable(s) also targeting preview: " + f"{', '.join(leaking_keys)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has no sensitive production environment " + f"variables leaking to preview deployments." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json new file mode 100644 index 0000000000..744a227d61 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "project_git_fork_protection_enabled", + "CheckTitle": "Vercel project has Git fork protection enabled to prevent untrusted forks from accessing secrets", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "devops", + "Description": "**Vercel projects** are assessed for **Git fork protection** configuration, which controls whether pull requests from forked repositories can trigger deployments and access environment variables. When disabled, anyone who forks a public repository can submit a pull request that triggers a Vercel build with access to the project's environment variables, including secrets and API keys.", + "Risk": "Without **Git fork protection**, an attacker can fork a public repository, modify the build process to **exfiltrate environment variables** (API keys, database credentials, third-party tokens), and submit a pull request. The Vercel build triggered by the PR would execute the attacker's code with access to the project's secrets, leading to **credential theft** and potential full system compromise.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/deployment-protection/managing-deployment-protection#git-fork-protection" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Scroll to the 'Git Fork Protection' section\n4. Enable the option to require authorization for fork pull requests\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Git fork protection to require explicit authorization before pull requests from forked repositories can trigger deployments. This prevents untrusted contributors from accessing environment variables and secrets through the build process. For open-source projects, review fork PRs manually before allowing builds.", + "Url": "https://hub.prowler.com/checks/vercel/project_git_fork_protection_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.py b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.py new file mode 100644 index 0000000000..b3b05e58c2 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled.py @@ -0,0 +1,43 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + + +class project_git_fork_protection_enabled(Check): + """Check if Git fork protection is enabled for the project. + + This class verifies whether each Vercel project has Git fork protection + enabled to prevent untrusted forks from accessing environment variables + and triggering deployments. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Project Git Fork Protection check. + + Iterates over all projects and checks if Git fork protection is enabled. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + if project.git_fork_protection: + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has Git fork protection enabled, " + f"preventing untrusted forks from accessing secrets." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} does not have Git fork protection " + f"enabled, allowing forks to access environment variables " + f"and trigger deployments." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_password_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json new file mode 100644 index 0000000000..58e3e46e41 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "project_password_protection_enabled", + "CheckTitle": "Vercel project has password protection configured for deployments", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **password protection** configuration, which adds a shared-password gate in front of deployments requiring visitors to enter a password before they can access the application. This provides an additional layer of access control beyond Vercel Authentication, useful for sharing preview deployments with external stakeholders who do not have Vercel accounts.", + "Risk": "Without **password protection**, deployments are accessible to anyone who has the URL. For projects that contain pre-release features, client work, or sensitive content, this means **unauthorized individuals** can view and interact with the application without any authentication barrier.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/deployment-protection/methods-to-protect-deployments/password-protection" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Scroll to the 'Password Protection' section\n4. Enable Password Protection and set a strong shared password\n5. Click Save\n6. Share the password only with authorized stakeholders", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable password protection to add a shared-password gate to your deployments. This is especially recommended for preview deployments shared with external clients or stakeholders who do not have Vercel accounts. Combine with Vercel Authentication for defense-in-depth.", + "Url": "https://hub.prowler.com/checks/vercel/project_password_protection_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "project_deployment_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py new file mode 100644 index 0000000000..86c0778408 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled.py @@ -0,0 +1,45 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + + +class project_password_protection_enabled(Check): + """Check if password protection is enabled for the project. + + This class verifies whether each Vercel project has password protection + configured to restrict access to deployments with a shared password. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Project Password Protection check. + + Iterates over all projects and checks if password protection is configured. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + if ( + project.password_protection + and isinstance(project.password_protection, dict) + and project.password_protection.get("deploymentType") + ): + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has password protection configured " + f"to restrict access to deployments." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} does not have password protection " + f"configured for deployments." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json new file mode 100644 index 0000000000..213bc51d07 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "project_production_deployment_protection_enabled", + "CheckTitle": "Vercel project has deployment protection enabled on production deployments", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **production deployment protection** configuration, which restricts access to the live production deployment by requiring Vercel Authentication or other access controls. When enabled, visitors must authenticate before accessing the production URL, adding a layer of defense for internal applications or projects that should not be publicly accessible.", + "Risk": "Without **production deployment protection**, the live production deployment is fully accessible to anyone on the internet. For internal tools, admin panels, or pre-launch applications this means **unauthorized users** can interact with production systems, potentially exploiting vulnerabilities, accessing sensitive data, or abusing application functionality.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/deployment-protection" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Scroll to Deployment Protection\n4. Under Production deployments, select 'Standard Protection' or 'Vercel Authentication'\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable deployment protection on production deployments for applications that should not be publicly accessible. This is critical for internal tools, admin dashboards, and pre-launch applications where unauthorized access could lead to data exposure or system compromise.", + "Url": "https://hub.prowler.com/checks/vercel/project_production_deployment_protection_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "project_deployment_protection_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py new file mode 100644 index 0000000000..ccbfd15325 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled.py @@ -0,0 +1,45 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + + +class project_production_deployment_protection_enabled(Check): + """Check if deployment protection is enabled on production deployments. + + This class verifies whether each Vercel project has deployment protection + configured for production deployments to prevent unauthorized public access. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Project Production Deployment Protection check. + + Iterates over all projects and checks if deployment protection is enabled + on production deployments. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + if ( + project.production_deployment_protection is not None + and project.production_deployment_protection.level != "none" + ): + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has production deployment protection " + f"enabled with level '{project.production_deployment_protection.level}'." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} does not have deployment protection " + f"enabled on production deployments." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/project/project_service.py b/prowler/providers/vercel/services/project/project_service.py new file mode 100644 index 0000000000..9b3b24859d --- /dev/null +++ b/prowler/providers/vercel/services/project/project_service.py @@ -0,0 +1,163 @@ +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.vercel.lib.service.service import VercelService + + +class Project(VercelService): + """Retrieve Vercel projects with security-relevant settings and environment variables.""" + + def __init__(self, provider): + super().__init__("Project", provider) + self.projects: dict[str, VercelProject] = {} + self._list_projects() + self.__threading_call__(self._fetch_env_vars, list(self.projects.values())) + + def _list_projects(self): + """List all projects, optionally filtered by --project argument.""" + try: + raw_projects = self._paginate("/v9/projects", "projects") + + filter_projects = self.provider.filter_projects + seen_ids: set[str] = set() + + for proj in raw_projects: + project_id = proj.get("id") + if not project_id or project_id in seen_ids: + continue + seen_ids.add(project_id) + + project_name = proj.get("name", "") + + # Apply project filter if specified + if filter_projects and ( + project_id not in filter_projects + and project_name not in filter_projects + ): + continue + + # Parse deployment protection + dp = None + dp_raw = proj.get("deploymentProtection", {}) or {} + + preview_dp = dp_raw.get("deploymentType", "none") + if preview_dp and preview_dp != "none": + dp = DeploymentProtectionConfig(level=preview_dp) + + prod_dp = None + prod_raw = dp_raw.get("prod", {}) or {} + prod_level = prod_raw.get("deploymentType", "none") + if prod_level and prod_level != "none": + prod_dp = DeploymentProtectionConfig(level=prod_level) + + # Parse password protection + pwd_protection = proj.get("passwordProtection") + + self.projects[project_id] = VercelProject( + id=project_id, + name=project_name, + team_id=proj.get("accountId") or self.provider.session.team_id, + framework=proj.get("framework"), + node_version=proj.get("nodeVersion"), + auto_expose_system_envs=proj.get("autoExposeSystemEnvs", False), + directory_listing=proj.get("directoryListing", False), + skew_protection=( + proj.get("skewProtection") == "enabled" + if isinstance(proj.get("skewProtection"), str) + else bool(proj.get("skewProtection", False)) + ), + deployment_protection=dp, + production_deployment_protection=prod_dp, + password_protection=pwd_protection, + git_fork_protection=proj.get("gitForkProtection", True), + git_repository=proj.get("link"), + secure_compute=proj.get("secureCompute"), + ) + + logger.info(f"Project - Found {len(self.projects)} project(s)") + + except Exception as error: + logger.error( + f"Project - Error listing projects: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_env_vars(self, project: "VercelProject"): + """Fetch environment variables for a single project.""" + try: + env_data = self._paginate(f"/v9/projects/{project.id}/env", "envs") + + env_vars = [] + for env in env_data: + env_vars.append( + VercelEnvironmentVariable( + id=env.get("id", ""), + key=env.get("key", ""), + type=env.get("type", "plain"), + target=env.get("target", []), + project_id=project.id, + project_name=project.name, + git_branch=env.get("gitBranch"), + created_at=( + datetime.fromtimestamp( + env["createdAt"] / 1000, tz=timezone.utc + ) + if env.get("createdAt") + else None + ), + ) + ) + + project.environment_variables = env_vars + logger.debug( + f"Project - Fetched {len(env_vars)} env vars for project {project.name}" + ) + + except Exception as error: + logger.error( + f"Project - Error fetching env vars for {project.name}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class DeploymentProtectionConfig(BaseModel): + """Per-environment deployment protection settings.""" + + level: str = "none" # "standard" | "all" | "none" + method: Optional[str] = None + + +class VercelEnvironmentVariable(BaseModel): + """Vercel project environment variable.""" + + id: str + key: str + type: str = "plain" # "encrypted" | "plain" | "secret" | "system" + target: list[str] = Field(default_factory=list) + project_id: str = "" + project_name: Optional[str] = None + git_branch: Optional[str] = None + created_at: Optional[datetime] = None + + +class VercelProject(BaseModel): + """Vercel project representation used across checks.""" + + id: str + name: str + team_id: Optional[str] = None + framework: Optional[str] = None + node_version: Optional[str] = None + auto_expose_system_envs: bool = False + directory_listing: bool = False + skew_protection: bool = False + deployment_protection: Optional[DeploymentProtectionConfig] = None + production_deployment_protection: Optional[DeploymentProtectionConfig] = None + password_protection: Optional[dict] = None + git_fork_protection: bool = True + git_repository: Optional[dict] = None + secure_compute: Optional[dict] = None + environment_variables: list[VercelEnvironmentVariable] = Field(default_factory=list) diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/__init__.py b/prowler/providers/vercel/services/project/project_skew_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json new file mode 100644 index 0000000000..b73aaa6991 --- /dev/null +++ b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "project_skew_protection_enabled", + "CheckTitle": "Vercel project has skew protection enabled to prevent version mismatches during deployments", + "CheckType": [], + "ServiceName": "project", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Vercel projects** are assessed for **skew protection**, which ensures clients always communicate with the correct deployment version during rollouts. Without it, clients may fetch assets or call APIs against a different version than the one that served the initial page, causing hydration errors or broken functionality.", + "Risk": "Without **skew protection**, users may experience **version mismatches** during deployment rollouts where the HTML is served from one deployment version but subsequent client-side navigation or API calls hit a newer version. This can cause broken user interfaces, failed client-side transitions, or **data corruption** from incompatible API contract changes.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/deployments/skew-protection" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > General\n3. Locate the 'Skew Protection' section\n4. Enable Skew Protection\n5. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable skew protection to ensure that all client requests during a deployment rollout are routed to the same deployment version that served the initial page. This prevents version mismatch errors and ensures a consistent user experience during deployments.", + "Url": "https://hub.prowler.com/checks/vercel/project_skew_protection_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py new file mode 100644 index 0000000000..f4e878ba5c --- /dev/null +++ b/prowler/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled.py @@ -0,0 +1,41 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.project.project_client import project_client + + +class project_skew_protection_enabled(Check): + """Check if skew protection is enabled for the project. + + This class verifies whether each Vercel project has skew protection enabled + to ensure clients are served consistent deployment versions during rollouts. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Project Skew Protection check. + + Iterates over all projects and checks if skew protection is enabled. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for project in project_client.projects.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=project) + + if project.skew_protection: + report.status = "PASS" + report.status_extended = ( + f"Project {project.name} has skew protection enabled, " + f"ensuring consistent deployment versions during rollouts." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.name} does not have skew protection enabled, " + f"which may cause version mismatches during deployments." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/__init__.py b/prowler/providers/vercel/services/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_client.py b/prowler/providers/vercel/services/security/security_client.py new file mode 100644 index 0000000000..f55b2177b0 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.services.security.security_service import Security + +security_client = Security(Provider.get_global_provider()) diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/__init__.py b/prowler/providers/vercel/services/security/security_custom_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json new file mode 100644 index 0000000000..c3f986b173 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "security_custom_rules_configured", + "CheckTitle": "Vercel project has custom firewall rules configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **custom firewall rule** configuration. Custom rules allow fine-grained control over traffic based on request attributes such as path, headers, user agent, and geographic location, providing application-specific protection beyond managed rulesets.", + "Risk": "Without **custom firewall rules**, the application lacks application-specific traffic filtering. Generic managed rulesets may not cover all threat vectors unique to the application. Custom rules are needed to block **known attack patterns**, restrict access to sensitive paths, and enforce application-level security policies.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall/custom-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Add custom rules to protect sensitive endpoints\n4. Configure conditions based on path, headers, geographic location, or other request attributes\n5. Set appropriate actions (block, challenge, or allow) for each rule", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure custom firewall rules to protect application-specific endpoints and enforce security policies. Focus on protecting admin panels, API routes, authentication endpoints, and any paths that handle sensitive data.", + "Url": "https://hub.prowler.com/checks/vercel/security_custom_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_waf_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py new file mode 100644 index 0000000000..6ec7f8834f --- /dev/null +++ b/prowler/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured.py @@ -0,0 +1,42 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_custom_rules_configured(Check): + """Check if custom firewall rules are configured for each project. + + This class verifies whether each Vercel project has at least one + custom firewall rule configured for application-specific protection. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Custom Rules Configuration check. + + Iterates over all firewall configurations and checks if at least + one custom rule is present. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.custom_rules: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has {len(config.custom_rules)} custom firewall rule(s) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have any custom firewall rules configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/__init__.py b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json new file mode 100644 index 0000000000..cc7712d4ec --- /dev/null +++ b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "security_ip_blocking_rules_configured", + "CheckTitle": "Vercel project has IP blocking rules configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **IP blocking rule** configuration. IP blocking rules allow denying access from known malicious IP addresses or ranges, reducing the attack surface and preventing traffic from untrusted sources.", + "Risk": "Without **IP blocking rules**, all traffic is accepted regardless of source IP. Known malicious IPs, abuse networks, and previously identified attackers can freely access the application. This increases the risk of **automated scanning**, credential stuffing, and targeted attacks from known threat sources.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Add a new IP blocking rule\n4. Specify the IP addresses or CIDR ranges to block\n5. Review firewall logs to identify malicious IPs that should be blocked", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure IP blocking rules to deny traffic from known malicious sources. Maintain a blocklist of IPs identified through security monitoring, threat intelligence feeds, or incident investigation. Regularly review and update the blocklist.", + "Url": "https://hub.prowler.com/checks/vercel/security_ip_blocking_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_waf_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py new file mode 100644 index 0000000000..0b89c8d111 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured.py @@ -0,0 +1,43 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_ip_blocking_rules_configured(Check): + """Check if IP blocking rules are configured for each project. + + This class verifies whether each Vercel project has at least one IP + blocking rule configured to restrict access from known malicious + IP addresses or ranges. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel IP Blocking Rules Configuration check. + + Iterates over all firewall configurations and checks if at least + one IP blocking rule is present. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.ip_blocking_rules: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has {len(config.ip_blocking_rules)} IP blocking rule(s) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have any IP blocking rules configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/__init__.py b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json new file mode 100644 index 0000000000..95d7db6d20 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "security_managed_rulesets_enabled", + "CheckTitle": "Vercel project has managed WAF rulesets enabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **managed WAF ruleset** enablement. Managed rulesets are curated by Vercel and provide protection against known attack patterns including **OWASP Top 10** threats. This feature requires an Enterprise plan and reports MANUAL status when unavailable.", + "Risk": "Without **managed rulesets** enabled, the firewall lacks curated protection rules against well-known attack patterns. The application relies solely on custom rules, which may miss **new or evolving threats** that managed rulesets are designed to detect and block automatically.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall/managed-rulesets" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Enable managed rulesets from the available options\n4. Review and configure ruleset sensitivity levels\n5. Note: This feature requires an Enterprise plan", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable managed WAF rulesets to benefit from Vercel-curated protection against common attack patterns. If you are on a plan that does not support managed rulesets, consider upgrading to the Enterprise plan for enhanced security features.", + "Url": "https://hub.prowler.com/checks/vercel/security_managed_rulesets_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_waf_enabled" + ], + "Notes": "This check is plan-gated. If the Vercel API returns a 403 for managed rulesets, the check reports MANUAL status indicating that an Enterprise plan is required." +} diff --git a/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py new file mode 100644 index 0000000000..ead4623f4c --- /dev/null +++ b/prowler/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled.py @@ -0,0 +1,52 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_managed_rulesets_enabled(Check): + """Check if managed WAF rulesets are enabled for each project. + + This class verifies whether each Vercel project has managed rulesets + enabled. Managed rulesets provide curated protection rules maintained + by Vercel against known attack patterns. This feature is plan-gated + and may not be available on all plans. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Managed Rulesets Enabled check. + + Iterates over all firewall configurations and checks if managed + rulesets are enabled. Reports MANUAL status when the feature is + not available due to plan limitations. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.managed_rulesets is None: + report.status = "MANUAL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"could not be assessed for managed rulesets. " + f"Enterprise plan required to access this feature." + ) + elif config.managed_rulesets: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has managed WAF rulesets enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have managed WAF rulesets enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/__init__.py b/prowler/providers/vercel/services/security/security_rate_limiting_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json new file mode 100644 index 0000000000..28a9c44d5f --- /dev/null +++ b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "vercel", + "CheckID": "security_rate_limiting_configured", + "CheckTitle": "Vercel project has rate limiting rules configured", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **rate limiting rule** configuration. Rate limiting protects applications from abuse, brute-force attacks, and DDoS attempts by restricting the number of requests from a single source within a given time window.", + "Risk": "Without **rate limiting**, the application is vulnerable to **brute-force attacks** on authentication endpoints, API abuse, resource exhaustion, and denial-of-service attacks. Attackers can overwhelm the application with excessive requests, degrading performance for legitimate users or exploiting endpoints without throttling.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security > Firewall\n3. Add a new rate limiting rule\n4. Configure the request threshold, time window, and action (challenge or block)\n5. Apply the rule to appropriate paths (e.g., /api/*, /login)", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure rate limiting rules to protect critical endpoints such as authentication, API routes, and form submissions. Start with conservative thresholds and adjust based on traffic patterns to avoid blocking legitimate users.", + "Url": "https://hub.prowler.com/checks/vercel/security_rate_limiting_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_waf_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py new file mode 100644 index 0000000000..3e6419666c --- /dev/null +++ b/prowler/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured.py @@ -0,0 +1,42 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_rate_limiting_configured(Check): + """Check if rate limiting rules are configured for each project. + + This class verifies whether each Vercel project has at least one rate + limiting rule configured to protect against abuse and DDoS attacks. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Rate Limiting Configuration check. + + Iterates over all firewall configurations and checks if at least + one rate limiting rule is present. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.rate_limiting_rules: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has {len(config.rate_limiting_rules)} rate limiting rule(s) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have any rate limiting rules configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/security/security_service.py b/prowler/providers/vercel/services/security/security_service.py new file mode 100644 index 0000000000..eadac856b9 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_service.py @@ -0,0 +1,127 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.vercel.lib.service.service import VercelService + + +class Security(VercelService): + """Retrieve Vercel WAF/Firewall configuration per project.""" + + def __init__(self, provider): + super().__init__("Security", provider) + self.firewall_configs: dict[str, VercelFirewallConfig] = {} + + # We need project IDs to fetch firewall configs + # Import project_client to get project list + from prowler.providers.vercel.services.project.project_client import ( + project_client, + ) + + self.__threading_call__( + self._fetch_firewall_config, list(project_client.projects.values()) + ) + + def _fetch_firewall_config(self, project): + """Fetch WAF/Firewall config for a single project.""" + try: + data = self._get( + "/v1/security/firewall/config", + params={"projectId": project.id}, + ) + + if data is None: + # 403 — plan limitation, store with managed_rulesets=None + self.firewall_configs[project.id] = VercelFirewallConfig( + project_id=project.id, + project_name=project.name, + team_id=project.team_id, + firewall_enabled=False, + managed_rulesets=None, + name=project.name, + id=project.id, + ) + return + + # Parse firewall config + fw = data.get("firewallConfig", data) if isinstance(data, dict) else {} + + # Determine if firewall is enabled + rules = fw.get("rules", []) or [] + managed = fw.get("managedRules", fw.get("managedRulesets")) + custom_rules = [] + ip_blocking = [] + rate_limiting = [] + + for rule in rules: + rule_action = rule.get("action", {}) + action_type = ( + rule_action.get("type", "") + if isinstance(rule_action, dict) + else str(rule_action) + ) + + if action_type == "rate_limit" or rule.get("rateLimit"): + rate_limiting.append(rule) + elif action_type in ("deny", "block") and self._is_ip_rule(rule): + ip_blocking.append(rule) + else: + custom_rules.append(rule) + + firewall_enabled = bool(rules) or bool(managed) + + self.firewall_configs[project.id] = VercelFirewallConfig( + project_id=project.id, + project_name=project.name, + team_id=project.team_id, + firewall_enabled=firewall_enabled, + managed_rulesets=managed if managed is not None else {}, + custom_rules=custom_rules, + ip_blocking_rules=ip_blocking, + rate_limiting_rules=rate_limiting, + name=project.name, + id=project.id, + ) + + logger.debug( + f"Security - Loaded firewall config for {project.name}: " + f"enabled={firewall_enabled}, rules={len(rules)}" + ) + + except Exception as error: + logger.error( + f"Security - Error fetching firewall config for {project.name}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + @staticmethod + def _is_ip_rule(rule: dict) -> bool: + """Check if a rule is an IP blocking rule based on conditions.""" + conditions = rule.get("conditionGroup", []) + for group in conditions: + for condition in group.get("conditions", []): + if condition.get("type") == "ip_address" or condition.get("op") in ( + "inc", + "eq", + ): + prop = condition.get("prop", "") + if "ip" in prop.lower(): + return True + return False + + +class VercelFirewallConfig(BaseModel): + """Vercel WAF/Firewall configuration per project.""" + + project_id: str + project_name: Optional[str] = None + team_id: Optional[str] = None + firewall_enabled: bool = False + managed_rulesets: Optional[dict] = None # None means plan-gated (403) + custom_rules: list[dict] = Field(default_factory=list) + ip_blocking_rules: list[dict] = Field(default_factory=list) + rate_limiting_rules: list[dict] = Field(default_factory=list) + # Provide name/id for CheckReportVercel + name: str = "" + id: str = "" diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/__init__.py b/prowler/providers/vercel/services/security/security_waf_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json new file mode 100644 index 0000000000..c758c82f18 --- /dev/null +++ b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "security_waf_enabled", + "CheckTitle": "Vercel project has the Web Application Firewall enabled", + "CheckType": [], + "ServiceName": "security", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "**Vercel projects** are assessed for **Web Application Firewall (WAF)** enablement. The WAF provides protection against common web attacks including **SQL injection**, **cross-site scripting (XSS)**, and other OWASP Top 10 threats.", + "Risk": "Without the **Web Application Firewall** enabled, the application is directly exposed to common web attacks including **SQL injection**, **cross-site scripting**, request smuggling, and other exploits. Attackers can exploit these vulnerabilities to steal data, deface the application, or gain unauthorized access.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/security/vercel-firewall" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Vercel dashboard\n2. Navigate to the project Settings > Security\n3. Enable the Web Application Firewall\n4. Configure appropriate rules and managed rulesets\n5. Monitor firewall logs for false positives and adjust rules accordingly", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the Vercel Web Application Firewall to protect your application against common web attacks. Start with managed rulesets for baseline protection and add custom rules as needed based on your application's threat model.", + "Url": "https://hub.prowler.com/checks/vercel/security_waf_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "security_managed_rulesets_enabled", + "security_custom_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py new file mode 100644 index 0000000000..82859a4f5c --- /dev/null +++ b/prowler/providers/vercel/services/security/security_waf_enabled/security_waf_enabled.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.security.security_client import security_client + + +class security_waf_enabled(Check): + """Check if the Vercel Web Application Firewall (WAF) is enabled. + + This class verifies whether each Vercel project has the Web Application + Firewall enabled to protect against common web attacks. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel WAF Enabled check. + + Iterates over all firewall configurations and checks if the WAF + is enabled for each project. + + Returns: + List[CheckReportVercel]: A list of reports for each project. + """ + findings = [] + for config in security_client.firewall_configs.values(): + report = CheckReportVercel(metadata=self.metadata(), resource=config) + + if config.managed_rulesets is None: + # 403 — plan limitation, cannot determine WAF status + report.status = "MANUAL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"could not be checked for WAF status due to plan limitations. " + f"Manual verification is required." + ) + elif config.firewall_enabled: + report.status = "PASS" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"has the Web Application Firewall enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Project {config.project_name} ({config.project_id}) " + f"does not have the Web Application Firewall enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/__init__.py b/prowler/providers/vercel/services/team/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_client.py b/prowler/providers/vercel/services/team/team_client.py new file mode 100644 index 0000000000..0930aa074b --- /dev/null +++ b/prowler/providers/vercel/services/team/team_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.services.team.team_service import Team + +team_client = Team(Provider.get_global_provider()) diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/__init__.py b/prowler/providers/vercel/services/team/team_directory_sync_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json new file mode 100644 index 0000000000..37019b79da --- /dev/null +++ b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "team_directory_sync_enabled", + "CheckTitle": "Vercel team has directory sync (SCIM) enabled for automated provisioning", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel team** is assessed for **directory sync (SCIM)** enablement. Directory sync automates user provisioning and deprovisioning by synchronizing team membership with an external identity provider, ensuring timely access revocation when employees leave.", + "Risk": "Without **directory sync**, user provisioning and deprovisioning must be managed manually, increasing the risk of **orphaned accounts** remaining active after employees leave or change roles. Manual processes are error-prone and may lead to unauthorized access persisting longer than intended.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles", + "https://vercel.com/docs/security/directory-sync" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Security section\n3. Configure directory sync (SCIM) with your identity provider\n4. Map identity provider groups to Vercel team roles\n5. Enable automated provisioning and deprovisioning", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable directory sync (SCIM) to automate user lifecycle management. This ensures that team membership stays synchronized with your identity provider, automatically provisioning new members and revoking access when employees leave or change roles.", + "Url": "https://hub.prowler.com/checks/vercel/team_directory_sync_enabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "team_saml_sso_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py new file mode 100644 index 0000000000..101922b6ed --- /dev/null +++ b/prowler/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled.py @@ -0,0 +1,47 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_directory_sync_enabled(Check): + """Check if directory sync (SCIM) is enabled for the Vercel team. + + This class verifies whether the Vercel team has directory sync enabled, + allowing automated user provisioning and deprovisioning through an + identity provider. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team Directory Sync Enabled check. + + Iterates over all teams and checks if directory sync is enabled. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + if team.directory_sync_enabled: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has directory sync (SCIM) enabled " + f"for automated user provisioning." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Team {team.name} does not have directory sync (SCIM) enabled. " + f"User provisioning and deprovisioning must be managed manually." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_member_role_least_privilege/__init__.py b/prowler/providers/vercel/services/team/team_member_role_least_privilege/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json new file mode 100644 index 0000000000..0e4750d703 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "team_member_role_least_privilege", + "CheckTitle": "Vercel team follows least privilege with a limited number of owner roles", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel team members** are assessed for **least privilege** by checking whether the proportion of members with the `OWNER` role exceeds a configurable percentage threshold (default: 20%) or a maximum owner count (default: 3). Both thresholds are configurable via audit_config. An excessive number of owners increases the attack surface and risk of accidental or malicious configuration changes.", + "Risk": "Having too many **team owners** increases the **blast radius** of compromised accounts and the risk of unauthorized changes to billing, security settings, and team membership. Each owner has full administrative privileges over the team.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Members section\n3. Review all members with the OWNER role\n4. Downgrade unnecessary owners to MEMBER, DEVELOPER, or VIEWER roles\n5. Ensure only essential personnel retain the OWNER role", + "Terraform": "" + }, + "Recommendation": { + "Text": "Limit the number of team owners to the minimum required for administration. Assign the least privileged role necessary for each member's responsibilities. Use MEMBER, DEVELOPER, or VIEWER roles for non-administrative team members.", + "Url": "https://hub.prowler.com/checks/vercel/team_member_role_least_privilege" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Thresholds are configurable via audit_config: max_owner_percentage (default: 20) and max_owners (default: 3). Small teams (<5 members) with at most 1 owner are always considered compliant." +} diff --git a/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.py b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.py new file mode 100644 index 0000000000..a2f122ffe8 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege.py @@ -0,0 +1,88 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_member_role_least_privilege(Check): + """Check if the Vercel team follows least privilege for owner roles. + + This class verifies that the number of team members with the OWNER + role does not exceed configurable thresholds, following the principle + of least privilege. Both the percentage threshold and a maximum owner + count are configurable via audit_config. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team Member Role Least Privilege check. + + Iterates over all teams and checks if the proportion of OWNER + members is within acceptable bounds. Thresholds are configurable + via ``max_owner_percentage`` (default: 20) and ``max_owners`` + (default: 3) in audit_config. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + max_owner_percentage = team_client.audit_config.get("max_owner_percentage", 20) + max_owners = team_client.audit_config.get("max_owners", 3) + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + active_members = [m for m in team.members if m.status == "active"] + total_active = len(active_members) + + if total_active == 0: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has no active members to evaluate." + ) + findings.append(report) + continue + + owners = [m for m in active_members if m.role == "OWNER"] + owner_count = len(owners) + owner_percentage = (owner_count / total_active) * 100 + + if total_active < 5 and owner_count <= 1: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has {owner_count} owner(s) out of " + f"{total_active} active members. Small team with minimum " + f"required owner — least privilege threshold not applicable." + ) + elif owner_percentage <= max_owner_percentage and owner_count <= max_owners: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has {owner_count} owner(s) out of " + f"{total_active} active members ({owner_percentage:.0f}%), " + f"which is within the configured thresholds " + f"({max_owner_percentage}% / max {max_owners} owners)." + ) + else: + reasons = [] + if owner_percentage > max_owner_percentage: + reasons.append( + f"{owner_percentage:.0f}% exceeds the " + f"{max_owner_percentage}% threshold" + ) + if owner_count > max_owners: + reasons.append( + f"{owner_count} owners exceeds the " f"maximum of {max_owners}" + ) + report.status = "FAIL" + report.status_extended = ( + f"Team {team.name} has {owner_count} owner(s) out of " + f"{total_active} active members — " + f"{'; '.join(reasons)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_no_stale_invitations/__init__.py b/prowler/providers/vercel/services/team/team_no_stale_invitations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json new file mode 100644 index 0000000000..d05461c5c1 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "vercel", + "CheckID": "team_no_stale_invitations", + "CheckTitle": "Vercel team has no stale pending invitations older than 30 days", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "governance", + "Description": "**Vercel team** is assessed for **stale invitations** by checking whether pending invitations have been outstanding for more than 30 days. Stale invitations may indicate abandoned onboarding processes or forgotten invitation links that could be exploited.", + "Risk": "**Stale pending invitations** represent unresolved access grants. If invitation links are intercepted or forwarded to unintended recipients, they could be used to gain **unauthorized access** to the team. Old invitations also indicate poor access lifecycle management.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Members section\n3. Review all pending invitations\n4. Revoke any invitations that have been pending for more than 30 days\n5. Re-invite members if still needed", + "Terraform": "" + }, + "Recommendation": { + "Text": "Regularly review and revoke stale team invitations. Establish a process to follow up on pending invitations within a reasonable timeframe and revoke those that are no longer needed to reduce the risk of unauthorized access.", + "Url": "https://hub.prowler.com/checks/vercel/team_no_stale_invitations" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.py b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.py new file mode 100644 index 0000000000..b57c0e37ab --- /dev/null +++ b/prowler/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations.py @@ -0,0 +1,70 @@ +from datetime import datetime, timezone +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_no_stale_invitations(Check): + """Check if the Vercel team has stale pending invitations. + + This class verifies that no team invitations have been pending for + more than 30 days, which may indicate abandoned or forgotten invitations + that should be revoked. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team No Stale Invitations check. + + Iterates over all teams and checks for pending invitations older + than 30 days. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + now = datetime.now(timezone.utc) + stale_threshold_days = team_client.audit_config.get( + "stale_invitation_threshold_days", 30 + ) + stale_invitations = [] + + for member in team.members: + if member.status != "invited": + continue + if member.created_at is None: + continue + + # Ensure created_at is timezone-aware for comparison + created_at = member.created_at + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + + age_days = (now - created_at).days + if age_days > stale_threshold_days: + stale_invitations.append(member) + + if not stale_invitations: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has no stale pending invitations " + f"older than {stale_threshold_days} days." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Team {team.name} has {len(stale_invitations)} stale " + f"pending invitation(s) older than {stale_threshold_days} days." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/__init__.py b/prowler/providers/vercel/services/team/team_saml_sso_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json new file mode 100644 index 0000000000..ebbe85ebc9 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "team_saml_sso_enabled", + "CheckTitle": "Vercel team has SAML SSO enabled for centralized identity management", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel team** is assessed for **SAML single sign-on (SSO)** enablement. SAML SSO enables centralized identity management through an external identity provider, ensuring consistent authentication policies across the organization.", + "Risk": "Without **SAML SSO**, team members authenticate using individual Vercel credentials that are not centrally managed. This increases the risk of **credential sprawl**, inconsistent password policies, and inability to enforce organization-wide authentication controls such as **MFA**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles", + "https://vercel.com/docs/security/saml" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Security section\n3. Configure SAML SSO with your identity provider (Okta, Azure AD, etc.)\n4. Complete the SAML connection setup and verify it is linked", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable SAML SSO for the Vercel team to centralize authentication through your organization's identity provider. This ensures consistent security policies, simplifies user lifecycle management, and enables enforcement of MFA and other access controls.", + "Url": "https://hub.prowler.com/checks/vercel/team_saml_sso_enabled" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "team_saml_sso_enforced" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py new file mode 100644 index 0000000000..8a979ec66d --- /dev/null +++ b/prowler/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled.py @@ -0,0 +1,45 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_saml_sso_enabled(Check): + """Check if SAML SSO is enabled for the Vercel team. + + This class verifies whether the Vercel team has SAML single sign-on + configured and enabled for centralized identity management. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team SAML SSO Enabled check. + + Iterates over all teams and checks if SAML SSO is enabled. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + if team.saml and team.saml.status == "enabled": + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} has SAML SSO enabled" + f"{f' via {team.saml.provider}' if team.saml.provider else ''}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Team {team.name} does not have SAML SSO enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/__init__.py b/prowler/providers/vercel/services/team/team_saml_sso_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json new file mode 100644 index 0000000000..f4de8e7ebe --- /dev/null +++ b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "vercel", + "CheckID": "team_saml_sso_enforced", + "CheckTitle": "Vercel team enforces SAML SSO for all members", + "CheckType": [], + "ServiceName": "team", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Vercel team** is assessed for **SAML SSO enforcement** across all members. When enforced, all team members must authenticate through the configured identity provider, preventing the use of individual Vercel credentials.", + "Risk": "Without **SAML SSO enforcement**, team members can bypass centralized authentication and log in with individual credentials even when SAML is configured. This undermines **identity governance**, allows circumvention of MFA policies, and creates gaps in access auditing.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://vercel.com/docs/accounts/team-members-and-roles", + "https://vercel.com/docs/security/saml" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to your Vercel team settings\n2. Go to the Security section\n3. Ensure SAML SSO is configured and linked\n4. Enable SSO enforcement to require all members to authenticate via the identity provider", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enforce SAML SSO for all team members to ensure authentication is managed exclusively through your identity provider. This prevents credential bypass, enforces MFA policies, and provides centralized access control and audit capabilities.", + "Url": "https://hub.prowler.com/checks/vercel/team_saml_sso_enforced" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "team_saml_sso_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py new file mode 100644 index 0000000000..746ebba387 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced.py @@ -0,0 +1,50 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportVercel +from prowler.providers.vercel.services.team.team_client import team_client + + +class team_saml_sso_enforced(Check): + """Check if SAML SSO enforcement is enabled for the Vercel team. + + This class verifies whether the Vercel team enforces SAML SSO, + requiring all members to authenticate through the identity provider. + """ + + def execute(self) -> List[CheckReportVercel]: + """Execute the Vercel Team SAML SSO Enforced check. + + Iterates over all teams and checks if SAML SSO is enforced. + + Returns: + List[CheckReportVercel]: A list of reports for each team. + """ + findings = [] + for team in team_client.teams.values(): + report = CheckReportVercel( + metadata=self.metadata(), + resource=team, + resource_name=team.name, + resource_id=team.id, + ) + + if team.saml and team.saml.enforced: + report.status = "PASS" + report.status_extended = ( + f"Team {team.name} enforces SAML SSO for all members." + ) + else: + report.status = "FAIL" + if team.saml and team.saml.status == "enabled": + report.status_extended = ( + f"Team {team.name} has SAML SSO enabled but does not enforce it. " + f"Members can still authenticate without SSO." + ) + else: + report.status_extended = ( + f"Team {team.name} does not have SAML SSO enforced." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/vercel/services/team/team_service.py b/prowler/providers/vercel/services/team/team_service.py new file mode 100644 index 0000000000..916374ea87 --- /dev/null +++ b/prowler/providers/vercel/services/team/team_service.py @@ -0,0 +1,157 @@ +from datetime import datetime, timezone +from typing import Optional + +from pydantic import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.vercel.lib.service.service import VercelService + + +class Team(VercelService): + """Retrieve Vercel team configuration and membership.""" + + def __init__(self, provider): + super().__init__("Team", provider) + self.teams: dict[str, VercelTeam] = {} + self._fetch_team() + + def _fetch_team(self): + """Fetch team details and members for all teams in scope.""" + team_ids = self._all_team_ids + if not team_ids: + logger.info("Team - No teams found, skipping team checks") + return + + for team_id in team_ids: + self._fetch_single_team(team_id) + + def _fetch_single_team(self, team_id: str): + """Fetch details and members for a single team.""" + try: + # Fetch team details (pass teamId explicitly for auto-discovered teams) + team_data = self._get(f"/v2/teams/{team_id}", params={"teamId": team_id}) + if not team_data: + return + + # Parse SAML config + saml_config = None + saml_raw = team_data.get("saml", {}) or {} + if saml_raw: + # Vercel returns saml.connection object when SAML is configured + connection = saml_raw.get("connection", {}) or {} + saml_config = SAMLConfig( + status=( + "enabled" + if connection.get("status") == "linked" + or saml_raw.get("status") == "enabled" + else "disabled" + ), + enforced=team_data.get("saml", {}).get("enforced", False) + or team_data.get("enabledSSOEnforcement", False), + provider=connection.get("type"), + ) + + # Parse directory sync + dir_sync = False + # Check for SCIM configuration + if team_data.get("enabledScim") or team_data.get("scim"): + dir_sync = True + + created_at = None + if team_data.get("createdAt"): + created_at = datetime.fromtimestamp( + team_data["createdAt"] / 1000, tz=timezone.utc + ) + + team = VercelTeam( + id=team_data.get("id", team_id), + name=team_data.get("name", ""), + slug=team_data.get("slug", ""), + saml=saml_config, + directory_sync_enabled=dir_sync, + created_at=created_at, + ) + + # Fetch members + self._fetch_members(team) + + self.teams[team.id] = team + logger.info( + f"Team - Loaded team {team.name} with {len(team.members)} members" + ) + + except Exception as error: + logger.error( + f"Team - Error fetching team {team_id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _fetch_members(self, team: "VercelTeam"): + """Fetch all members for a team.""" + try: + raw_members = self._paginate( + f"/v2/teams/{team.id}/members", + "members", + params={"teamId": team.id}, + ) + + for member in raw_members: + joined_at = None + if member.get("joinedFrom", {}).get("commitAt"): + joined_at = datetime.fromtimestamp( + member["joinedFrom"]["commitAt"] / 1000, tz=timezone.utc + ) + elif member.get("createdAt"): + joined_at = datetime.fromtimestamp( + member["createdAt"] / 1000, tz=timezone.utc + ) + + created_at = None + if member.get("createdAt"): + created_at = datetime.fromtimestamp( + member["createdAt"] / 1000, tz=timezone.utc + ) + + team.members.append( + VercelTeamMember( + id=member.get("uid", member.get("id", "")), + email=member.get("email", ""), + role=member.get("role", "MEMBER"), + status=( + "invited" if member.get("confirmed") is False else "active" + ), + joined_at=joined_at, + created_at=created_at, + ) + ) + + except Exception as error: + logger.error( + f"Team - Error fetching members for {team.name}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class SAMLConfig(BaseModel): + status: str = "disabled" # "enabled" | "disabled" + enforced: bool = False + provider: Optional[str] = None + + +class VercelTeamMember(BaseModel): + id: str + email: str + role: str # "OWNER" | "MEMBER" | "DEVELOPER" | "VIEWER" | "BILLING" + status: str = "active" # "active" | "invited" + joined_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + +class VercelTeam(BaseModel): + id: str + name: str + slug: str + saml: Optional[SAMLConfig] = None + directory_sync_enabled: bool = False + members: list[VercelTeamMember] = Field(default_factory=list) + created_at: Optional[datetime] = None diff --git a/prowler/providers/vercel/vercel_provider.py b/prowler/providers/vercel/vercel_provider.py new file mode 100644 index 0000000000..731adef6ed --- /dev/null +++ b/prowler/providers/vercel/vercel_provider.py @@ -0,0 +1,405 @@ +import os + +import requests +from colorama import Fore, Style + +from prowler.config.config import ( + default_config_file_path, + get_default_mute_file_path, + load_and_validate_config_file, +) +from prowler.lib.logger import logger +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata, Connection +from prowler.providers.common.provider import Provider +from prowler.providers.vercel.exceptions.exceptions import ( + VercelAuthenticationError, + VercelCredentialsError, + VercelIdentityError, + VercelInvalidTeamError, + VercelRateLimitError, + VercelSessionError, +) +from prowler.providers.vercel.lib.mutelist.mutelist import VercelMutelist +from prowler.providers.vercel.models import ( + VercelIdentityInfo, + VercelSession, + VercelTeamInfo, +) + + +class VercelProvider(Provider): + """Vercel provider.""" + + _type: str = "vercel" + _session: VercelSession + _identity: VercelIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: VercelMutelist + _filter_projects: set[str] | None + audit_metadata: Audit_Metadata + + def __init__( + self, + # Authentication credentials + api_token: str = None, + team_id: str = None, + # Scope + projects: list[str] | None = None, + # Provider configuration + config_path: str = None, + config_content: dict | None = None, + fixer_config: dict = {}, + mutelist_path: str = None, + mutelist_content: dict = None, + ): + logger.info("Instantiating Vercel provider...") + + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + self._session = VercelProvider.setup_session( + api_token=api_token, + team_id=team_id, + ) + + self._identity = VercelProvider.setup_identity(self._session) + + self._fixer_config = fixer_config + + if mutelist_content: + self._mutelist = VercelMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = VercelMutelist(mutelist_path=mutelist_path) + + # Store project filter for filtering resources across services + self._filter_projects = set(projects) if projects else None + + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def session(self): + return self._session + + @property + def identity(self): + return self._identity + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + @property + def mutelist(self) -> VercelMutelist: + return self._mutelist + + @property + def filter_projects(self) -> set[str] | None: + """Project filter from --project argument to filter scanned projects.""" + return self._filter_projects + + @staticmethod + def setup_session( + api_token: str = None, + team_id: str = None, + ) -> VercelSession: + """Initialize Vercel API session. + + Credentials can be provided as arguments (for API use) or read from + environment variables: + - VERCEL_TOKEN (API Bearer Token) + - VERCEL_TEAM (Team ID or slug, optional) + + Args: + api_token: Vercel API token (optional, falls back to VERCEL_TOKEN env var). + team_id: Vercel team ID or slug (optional, falls back to VERCEL_TEAM env var). + + Returns: + VercelSession: The initialized Vercel session. + + Raises: + VercelCredentialsError: If no credentials are provided. + VercelSessionError: If session setup fails. + """ + token = api_token or os.environ.get("VERCEL_TOKEN", "") + team = team_id or os.environ.get("VERCEL_TEAM", "") or None + + if not token: + raise VercelCredentialsError( + file=os.path.basename(__file__), + message="Vercel credentials not found. Provide an api_token or set the VERCEL_TOKEN environment variable.", + ) + + try: + http_session = requests.Session() + http_session.headers.update( + { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + ) + + return VercelSession( + token=token, + team_id=team, + http_session=http_session, + ) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise VercelSessionError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def setup_identity(session: VercelSession) -> VercelIdentityInfo: + """Fetch user and team metadata for Vercel. + + Args: + session: The Vercel session. + + Returns: + VercelIdentityInfo: The identity information. + + Raises: + VercelIdentityError: If identity setup fails. + """ + try: + http = session.http_session + params = {"teamId": session.team_id} if session.team_id else {} + + # Get user info + response = http.get( + f"{session.base_url}/v2/user", params=params, timeout=30 + ) + response.raise_for_status() + user_data = response.json().get("user", {}) + + user_id = user_data.get("id") + username = user_data.get("username") + email = user_data.get("email") + + # Get team info + team_info = None + all_teams = [] + + if session.team_id: + # Specific team requested — fetch just that one + params = {"teamId": session.team_id} + team_response = http.get( + f"{session.base_url}/v2/teams/{session.team_id}", + params=params, + timeout=30, + ) + if team_response.status_code == 200: + team_data = team_response.json() + team_info = VercelTeamInfo( + id=team_data.get("id", session.team_id), + name=team_data.get("name", ""), + slug=team_data.get("slug", ""), + ) + all_teams = [team_info] + elif team_response.status_code in (404, 403): + raise VercelInvalidTeamError( + file=os.path.basename(__file__), + message=f"Team '{session.team_id}' not found or not accessible.", + ) + else: + team_response.raise_for_status() + else: + # No team specified — auto-discover all teams the user belongs to + try: + teams_response = http.get( + f"{session.base_url}/v2/teams", + params={"limit": 100}, + timeout=30, + ) + if teams_response.status_code == 200: + teams_data = teams_response.json().get("teams", []) + for t in teams_data: + all_teams.append( + VercelTeamInfo( + id=t.get("id", ""), + name=t.get("name", ""), + slug=t.get("slug", ""), + ) + ) + if all_teams: + logger.info( + f"Auto-discovered {len(all_teams)} team(s): " + f"{', '.join(t.name for t in all_teams)}" + ) + except Exception as teams_error: + logger.warning(f"Could not auto-discover teams: {teams_error}") + + return VercelIdentityInfo( + user_id=user_id, + username=username, + email=email, + team=team_info, + teams=all_teams, + ) + except VercelInvalidTeamError: + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise VercelIdentityError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def validate_credentials(session: VercelSession) -> None: + """Validate Vercel credentials by calling GET /v2/user. + + Args: + session: The Vercel session to validate. + + Raises: + VercelAuthenticationError: If authentication fails. + VercelRateLimitError: If rate limited. + """ + try: + params = {} + if session.team_id: + params["teamId"] = session.team_id + response = session.http_session.get( + f"{session.base_url}/v2/user", params=params, timeout=30 + ) + + if response.status_code == 401: + raise VercelAuthenticationError( + file=os.path.basename(__file__), + message="Invalid or expired Vercel API token.", + ) + + if response.status_code == 403: + raise VercelAuthenticationError( + file=os.path.basename(__file__), + message="Insufficient permissions for the Vercel API token.", + ) + + if response.status_code == 429: + raise VercelRateLimitError( + file=os.path.basename(__file__), + ) + + response.raise_for_status() + + except (VercelAuthenticationError, VercelRateLimitError): + raise + except requests.exceptions.RequestException as error: + raise VercelAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + + def print_credentials(self) -> None: + report_title = ( + f"{Style.BRIGHT}Using the Vercel credentials below:{Style.RESET_ALL}" + ) + report_lines = [] + + report_lines.append(f"Authentication: {Fore.YELLOW}API Token{Style.RESET_ALL}") + + if self.identity.email: + report_lines.append( + f"Email: {Fore.YELLOW}{self.identity.email}{Style.RESET_ALL}" + ) + + if self.identity.username: + report_lines.append( + f"Username: {Fore.YELLOW}{self.identity.username}{Style.RESET_ALL}" + ) + + if self.identity.team: + report_lines.append( + f"Team: {Fore.YELLOW}{self.identity.team.name} ({self.identity.team.slug}){Style.RESET_ALL}" + ) + elif self.identity.teams: + team_names = ", ".join(f"{t.name} ({t.slug})" for t in self.identity.teams) + report_lines.append( + f"Scope: {Fore.YELLOW}Personal Account + {len(self.identity.teams)} team(s): {team_names}{Style.RESET_ALL}" + ) + else: + report_lines.append( + f"Scope: {Fore.YELLOW}Personal Account{Style.RESET_ALL}" + ) + + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + api_token: str = None, + team_id: str = None, + raise_on_exception: bool = True, + provider_id: str = None, + ) -> Connection: + """Test connection to Vercel. + + Credentials can be provided as arguments (for API use) or read from + environment variables (VERCEL_TOKEN, VERCEL_TEAM). + + Args: + api_token: Vercel API token (optional, falls back to env var). + team_id: Vercel team ID or slug (optional, falls back to env var). + raise_on_exception: Whether to raise or return errors. + provider_id: The provider ID. + + Returns: + Connection: Connection object with is_connected status. + """ + try: + session = VercelProvider.setup_session( + api_token=api_token, + team_id=team_id, + ) + VercelProvider.validate_credentials(session) + return Connection(is_connected=True) + + except ( + VercelCredentialsError, + VercelSessionError, + VercelAuthenticationError, + VercelRateLimitError, + ) as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise error + return Connection(is_connected=False, error=error) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + formatted_error = VercelAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + if raise_on_exception: + raise formatted_error + return Connection(is_connected=False, error=formatted_error) + + def validate_arguments(self) -> None: + return None diff --git a/pyproject.toml b/pyproject.toml index 177683020a..a3b6f7dece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,9 @@ requires = ["poetry-core>=2.0"] authors = [{name = "Toni de la Fuente", email = "toni@blyx.com"}] classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "License :: OSI Approved :: Apache Software License" ] dependencies = [ @@ -44,7 +46,7 @@ dependencies = [ "boto3==1.40.61", "botocore==1.40.61", "colorama==0.4.6", - "cryptography==44.0.3", + "cryptography==46.0.6", "dash==3.1.1", "dash-bootstrap-components==2.0.3", "defusedxml>=0.7.1", @@ -54,7 +56,7 @@ dependencies = [ "google-auth-httplib2>=0.1,<0.3", "jsonschema==4.23.0", "kubernetes==32.0.1", - "markdown==3.9.0", + "markdown==3.10.2", "microsoft-kiota-abstractions==1.9.2", "msgraph-sdk==1.23.0", "numpy==2.0.2", @@ -62,7 +64,7 @@ dependencies = [ "pandas==2.2.3", "py-ocsf-models==0.8.1", "pydantic (>=2.0,<3.0)", - "pygithub==2.5.0", + "pygithub==2.8.0", "python-dateutil (>=2.9.0.post0,<3.0.0)", "pytz==2025.1", "schema==0.7.5", @@ -73,10 +75,10 @@ dependencies = [ "uuid6==2024.7.10", "py-iam-expand==0.1.0", "h2==4.3.0", - "oci==2.160.3", + "oci==2.169.0", "alibabacloud_credentials==1.0.3", "alibabacloud_ram20150501==1.2.0", - "alibabacloud_tea_openapi==0.4.1", + "alibabacloud_tea_openapi==0.4.4", "alibabacloud_sts20150401==1.1.6", "alibabacloud_vpc20160428==6.13.0", "alibabacloud_ecs20140526==7.2.5", @@ -93,8 +95,8 @@ license = "Apache-2.0" maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] name = "prowler" readme = "README.md" -requires-python = ">3.9.1,<3.13" -version = "5.19.0" +requires-python = ">=3.10,<3.13" +version = "5.23.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -117,13 +119,10 @@ bandit = "1.8.3" black = "25.1.0" coverage = "7.6.12" docker = "7.1.0" -filelock = [ - {version = "3.20.3", python = ">=3.10"}, - {version = "3.19.1", python = "<3.10"} -] +filelock = "3.20.3" flake8 = "7.1.2" freezegun = "1.5.1" -marshmallow = ">=3.15.0,<4.0.0" +marshmallow = "==3.26.2" mock = "5.2.0" moto = {extras = ["all"], version = "5.1.11"} openapi-schema-validator = "0.6.3" diff --git a/scripts/validate_ocsf_output.py b/scripts/validate_ocsf_output.py new file mode 100755 index 0000000000..f97de65db6 --- /dev/null +++ b/scripts/validate_ocsf_output.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +OCSF Output Validator + +Validates OCSF JSON output files for Prowler Cloud integration requirements: +- finding_info.uid uniqueness across all findings +- resources[*].uid populated for every resource + +Usage: + python validate_ocsf_output.py [...] + +Example: + python validate_ocsf_output.py output/*.ocsf.json +""" + +import glob +import json +import sys +from argparse import ArgumentParser +from pathlib import Path + + +def load_ocsf_file(path: str) -> list[dict]: + """Load and parse an OCSF JSON file containing an array of findings.""" + file_path = Path(path) + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {path}") + if not file_path.suffix == ".json" and not path.endswith(".ocsf.json"): + raise ValueError(f"Expected .ocsf.json file, got: {path}") + + with open(file_path) as f: + data = json.load(f) + + if not isinstance(data, list): + raise ValueError(f"Expected JSON array, got {type(data).__name__}") + + return data + + +def validate_unique_finding_uids(findings: list[dict]) -> list[str]: + """Check that finding_info.uid is present and unique across all findings.""" + errors = [] + seen = {} + + for idx, finding in enumerate(findings): + finding_info = finding.get("finding_info") + if not finding_info or not isinstance(finding_info, dict): + errors.append(f"Finding [{idx}]: missing 'finding_info' object") + continue + + uid = finding_info.get("uid") + if not uid: + errors.append(f"Finding [{idx}]: missing 'finding_info.uid'") + continue + + if uid in seen: + errors.append( + f"Finding [{idx}]: duplicate 'finding_info.uid' = '{uid}' " + f"(first seen at index {seen[uid]})" + ) + else: + seen[uid] = idx + + return errors + + +def validate_resources_uid(findings: list[dict]) -> list[str]: + """Check that every resource in every finding has a non-empty uid.""" + errors = [] + + for idx, finding in enumerate(findings): + resources = finding.get("resources") + if not resources: + errors.append(f"Finding [{idx}]: missing or empty 'resources' array") + continue + + if not isinstance(resources, list): + errors.append(f"Finding [{idx}]: 'resources' is not an array") + continue + + for res_idx, resource in enumerate(resources): + uid = resource.get("uid") + if not uid or (isinstance(uid, str) and not uid.strip()): + errors.append( + f"Finding [{idx}], resource [{res_idx}]: " + f"missing or empty 'resources[].uid'" + ) + + return errors + + +def validate_file(path: str) -> dict: + """Run all validations on a single OCSF file.""" + result = {"file": path, "valid": True, "errors": [], "finding_count": 0} + + try: + findings = load_ocsf_file(path) + except (FileNotFoundError, ValueError, json.JSONDecodeError) as e: + result["valid"] = False + result["errors"].append(str(e)) + return result + + result["finding_count"] = len(findings) + + if not findings: + return result + + uid_errors = validate_unique_finding_uids(findings) + resource_errors = validate_resources_uid(findings) + + all_errors = uid_errors + resource_errors + if all_errors: + result["valid"] = False + result["errors"] = all_errors + + return result + + +def print_report(results: list[dict]): + """Print a formatted validation report.""" + print("\n" + "=" * 60) + print("OCSF OUTPUT VALIDATION REPORT") + print("=" * 60) + + total_files = len(results) + passed = sum(1 for r in results if r["valid"]) + failed = total_files - passed + total_findings = sum(r["finding_count"] for r in results) + + for result in results: + print(f"\nFile: {result['file']}") + print(f" Findings: {result['finding_count']}") + + if result["valid"]: + print(" Status: PASS") + else: + print(" Status: FAIL") + for error in result["errors"]: + print(f" [X] {error}") + + print("\n" + "-" * 60) + print(f"Files: {total_files} | Findings: {total_findings}") + print(f"Passed: {passed} | Failed: {failed}") + print("-" * 60) + + if failed == 0: + print("RESULT: PASS") + else: + print("RESULT: FAIL") + print("=" * 60 + "\n") + + +def main(): + parser = ArgumentParser( + description="Validate OCSF output files for Prowler Cloud integration" + ) + parser.add_argument( + "files", + nargs="+", + help="OCSF JSON file path(s) or glob pattern(s)", + ) + args = parser.parse_args() + + # Expand glob patterns + file_paths = [] + for pattern in args.files: + expanded = glob.glob(pattern) + if expanded: + file_paths.extend(expanded) + else: + file_paths.append(pattern) + + if not file_paths: + print("Error: No files matched the provided pattern(s).") + sys.exit(1) + + results = [validate_file(path) for path in file_paths] + print_report(results) + + sys.exit(0 if all(r["valid"] for r in results) else 1) + + +if __name__ == "__main__": + main() diff --git a/skills/django-migration-psql/SKILL.md b/skills/django-migration-psql/SKILL.md new file mode 100644 index 0000000000..d292036dd0 --- /dev/null +++ b/skills/django-migration-psql/SKILL.md @@ -0,0 +1,454 @@ +--- +name: django-migration-psql +description: > + Reviews Django migration files for PostgreSQL best practices specific to Prowler. + Trigger: When creating migrations, running makemigrations/pgmakemigrations, reviewing migration PRs, + adding indexes or constraints to database tables, modifying existing migration files, or writing + data backfill migrations. Always use this skill when you see AddIndex, CreateModel, AddConstraint, + RunPython, bulk_create, bulk_update, or backfill operations in migration files. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [api, root] + auto_invoke: + - "Creating or reviewing Django migrations" + - "Adding indexes or constraints to database tables" + - "Running makemigrations or pgmakemigrations" + - "Writing data backfill or data migration" +allowed-tools: Read, Grep, Glob, Edit, Write, Bash +--- + +## When to use + +- Creating a new Django migration +- Running `makemigrations` or `pgmakemigrations` +- Reviewing a PR that adds or modifies migrations +- Adding indexes, constraints, or models to the database + +## Why this matters + +A bad migration can lock a production table for minutes, block all reads/writes, or silently skip index creation on partitioned tables. + +## Auto-generated migrations need splitting + +`makemigrations` and `pgmakemigrations` bundle everything into one file: `CreateModel`, `AddIndex`, `AddConstraint`, sometimes across multiple tables. This is the default Django behavior and it violates every rule below. + +After generating a migration, ALWAYS review it and split it: + +1. Read the generated file and identify every operation +2. Group operations by concern: + - `CreateModel` + `AddConstraint` for each new table → one migration per table + - `AddIndex` per table → one migration per table + - `AddIndex` on partitioned tables → two migrations (partition + parent) + - `AlterField`, `AddField`, `RemoveField` for each table → one migration per table +3. Rewrite the generated file into separate migration files with correct dependencies +4. Delete the original auto-generated migration + +When adding fields or indexes to an existing model, `makemigrations` may also bundle `AddIndex` for unrelated tables that had pending model changes. Always check for stowaways from other tables. + +## Rule 1: separate indexes from model creation + +`CreateModel` + `AddConstraint` = same migration (structural). +`AddIndex` = separate migration file (performance). + +Django runs each migration inside a transaction (unless `atomic = False`). If an index operation fails, it rolls back everything, including the model creation. Splitting means a failed index doesn't prevent the table from existing. It also lets you `--fake` index migrations independently (see Rule 4). + +### Bad + +```python +# 0081_finding_group_daily_summary.py — DON'T DO THIS +class Migration(migrations.Migration): + operations = [ + migrations.CreateModel(name="FindingGroupDailySummary", ...), + migrations.AddIndex(model_name="findinggroupdailysummary", ...), # separate this + migrations.AddIndex(model_name="findinggroupdailysummary", ...), # separate this + migrations.AddConstraint(model_name="findinggroupdailysummary", ...), # this is fine here + ] +``` + +### Good + +```python +# 0081_create_finding_group_daily_summary.py +class Migration(migrations.Migration): + operations = [ + migrations.CreateModel(name="FindingGroupDailySummary", ...), + # Constraints belong with the model — they define its integrity rules + migrations.AddConstraint(model_name="findinggroupdailysummary", ...), # unique + migrations.AddConstraint(model_name="findinggroupdailysummary", ...), # RLS + ] + +# 0082_finding_group_daily_summary_indexes.py +class Migration(migrations.Migration): + dependencies = [("api", "0081_create_finding_group_daily_summary")] + operations = [ + migrations.AddIndex(model_name="findinggroupdailysummary", ...), + migrations.AddIndex(model_name="findinggroupdailysummary", ...), + migrations.AddIndex(model_name="findinggroupdailysummary", ...), + ] +``` + +Flag any migration with both `CreateModel` and `AddIndex` in `operations`. + +## Rule 2: one table's indexes per migration + +Each table's indexes must live in their own migration file. Never mix `AddIndex` for different `model_name` values in one migration. + +If the index on table B fails, the rollback also drops the index on table A. The migration name gives no hint that it touches unrelated tables. You lose the ability to `--fake` one table's indexes without affecting the other. + +### Bad + +```python +# 0081_finding_group_daily_summary.py — DON'T DO THIS +class Migration(migrations.Migration): + operations = [ + migrations.CreateModel(name="FindingGroupDailySummary", ...), + migrations.AddIndex(model_name="findinggroupdailysummary", ...), # table A + migrations.AddIndex(model_name="resource", ...), # table B! + migrations.AddIndex(model_name="resource", ...), # table B! + migrations.AddIndex(model_name="finding", ...), # table C! + ] +``` + +### Good + +```python +# 0081_create_finding_group_daily_summary.py — model + constraints +# 0082_finding_group_daily_summary_indexes.py — only FindingGroupDailySummary indexes +# 0083_resource_trigram_indexes.py — only Resource indexes +# 0084_finding_check_index_partitions.py — only Finding partition indexes (step 1) +# 0085_finding_check_index_parent.py — only Finding parent index (step 2) +``` + +Name each migration file after the table it affects. A reviewer should know which table a migration touches without opening the file. + +Flag any migration where `AddIndex` operations reference more than one `model_name`. + +## Rule 3: partitioned table indexes require the two-step pattern + +Tables `findings` and `resource_finding_mappings` are range-partitioned. Plain `AddIndex` only creates the index definition on the parent table. Postgres does NOT propagate it to existing partitions. New partitions inherit it, but all current data stays unindexed. + +Use the helpers in `api.db_utils`. + +### Step 1: create indexes on actual partitions + +```python +# 0084_finding_check_index_partitions.py +from functools import partial +from django.db import migrations +from api.db_utils import create_index_on_partitions, drop_index_on_partitions + + +class Migration(migrations.Migration): + atomic = False # REQUIRED — CREATE INDEX CONCURRENTLY can't run inside a transaction + + dependencies = [("api", "0083_resource_trigram_indexes")] + + operations = [ + migrations.RunPython( + partial( + create_index_on_partitions, + parent_table="findings", + index_name="find_tenant_check_ins_idx", + columns="tenant_id, check_id, inserted_at", + ), + reverse_code=partial( + drop_index_on_partitions, + parent_table="findings", + index_name="find_tenant_check_ins_idx", + ), + ) + ] +``` + +Key details: +- `atomic = False` is mandatory. `CREATE INDEX CONCURRENTLY` cannot run inside a transaction. +- Always provide `reverse_code` using `drop_index_on_partitions` so rollbacks work. +- The default is `all_partitions=True`, which creates indexes on every partition CONCURRENTLY (no locks). This is the safe default. +- Do NOT use `all_partitions=False` unless you understand the consequence: Step 2's `AddIndex` on the parent will create indexes on the skipped partitions **with locks** (not CONCURRENTLY), because PostgreSQL fills in missing partition indexes inline during parent index creation. + +### Step 2: register the index with Django + +```python +# 0085_finding_check_index_parent.py +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [("api", "0084_finding_check_index_partitions")] + + operations = [ + migrations.AddIndex( + model_name="finding", + index=models.Index( + fields=["tenant_id", "check_id", "inserted_at"], + name="find_tenant_check_ins_idx", + ), + ), + ] +``` + +This second migration tells Django "this index exists" so it doesn't try to recreate it. New partitions created after this point inherit the index definition from the parent. + +### Existing examples in the codebase + +| Partition migration | Parent migration | +|---|---| +| `0020_findings_new_performance_indexes_partitions.py` | `0021_findings_new_performance_indexes_parent.py` | +| `0024_findings_uid_index_partitions.py` | `0025_findings_uid_index_parent.py` | +| `0028_findings_check_index_partitions.py` | `0029_findings_check_index_parent.py` | +| `0036_rfm_tenant_finding_index_partitions.py` | `0037_rfm_tenant_finding_index_parent.py` | + +Flag any plain `AddIndex` on `finding` or `resourcefindingmapping` without a preceding partition migration. + +## Rule 4: large table indexes — fake the migration, apply manually + +For huge tables (findings has millions of rows), even `CREATE INDEX CONCURRENTLY` can take minutes and consume significant I/O. In production, you may want to decouple the migration from the actual index creation. + +### Procedure + +1. Write the migration normally following the two-step pattern above. + +2. Fake the migration so Django marks it as applied without executing it: + +```bash +python manage.py migrate api 0084_finding_check_index_partitions --fake +python manage.py migrate api 0085_finding_check_index_parent --fake +``` + +3. Create the index manually during a low-traffic window via `psql` or `python manage.py dbshell --database admin`: + +```sql +-- For each partition you care about: +CREATE INDEX CONCURRENTLY IF NOT EXISTS findings_2026_jan_find_tenant_check_ins_idx + ON findings_2026_jan USING BTREE (tenant_id, check_id, inserted_at); + +CREATE INDEX CONCURRENTLY IF NOT EXISTS findings_2026_feb_find_tenant_check_ins_idx + ON findings_2026_feb USING BTREE (tenant_id, check_id, inserted_at); + +-- Then register on the parent (this is fast, no data scan): +CREATE INDEX IF NOT EXISTS find_tenant_check_ins_idx + ON findings USING BTREE (tenant_id, check_id, inserted_at); +``` + +4. Verify the index exists on the partitions you need: + +```sql +SELECT indexrelid::regclass, indrelid::regclass +FROM pg_index +WHERE indexrelid::regclass::text LIKE '%find_tenant_check_ins%'; +``` + +### When to use this approach + +- The table will grow exponentially, e.g.: findings. +- You want to control exactly when the I/O hit happens (e.g., during a maintenance window). + +This is optional. For smaller tables or non-production environments, letting the migration run normally is fine. + +## Rule 5: data backfills — never inline, always batched + +Data backfills (updating existing rows, populating new columns, generating summary data) are the most dangerous migrations. A naive `Model.objects.all().update(...)` on a multi-million row table will hold a transaction lock for minutes, blow out WAL, and potentially OOM the worker. + +### Never backfill inline in the migration + +The migration should only dispatch the work. The actual backfill runs asynchronously via Celery tasks, outside the migration transaction. + +```python +# 0090_backfill_finding_group_summaries.py +from django.db import migrations + +def trigger_backfill(apps, schema_editor): + from tasks.jobs.backfill import backfill_finding_group_summaries_task + Tenant = apps.get_model("api", "Tenant") + from api.db_router import MainRouter + + tenant_ids = Tenant.objects.using(MainRouter.admin_db).values_list("id", flat=True) + for tenant_id in tenant_ids: + backfill_finding_group_summaries_task.delay(tenant_id=str(tenant_id)) + +class Migration(migrations.Migration): + dependencies = [("api", "0089_previous_migration")] + operations = [ + migrations.RunPython(trigger_backfill, migrations.RunPython.noop), + ] +``` + +The migration finishes in seconds. The backfill runs in the background per-tenant. + +### Exception: trivial updates + +Single-statement bulk updates on small result sets are OK inline: + +```python +# Fine — single UPDATE, small result set, no iteration +def backfill_graph_data_ready(apps, schema_editor): + AttackPathsScan = apps.get_model("api", "AttackPathsScan") + AttackPathsScan.objects.using(MainRouter.admin_db).filter( + state="completed", graph_data_ready=False, + ).update(graph_data_ready=True) +``` + +Use inline only when you're confident the affected row count is small (< ~10K rows). + +### Batch processing in the Celery task + +The actual backfill task must process data in batches. Use the helpers in `api.db_utils`: + +```python +from api.db_utils import create_objects_in_batches, update_objects_in_batches, batch_delete + +# Creating objects in batches (500 per transaction) +create_objects_in_batches(tenant_id, ScanCategorySummary, summaries, batch_size=500) + +# Updating objects in batches +update_objects_in_batches(tenant_id, Finding, findings, fields=["status"], batch_size=500) + +# Deleting in batches +batch_delete(tenant_id, queryset, batch_size=settings.DJANGO_DELETION_BATCH_SIZE) +``` + +Each batch runs in its own `rls_transaction()` so: +- A failure in batch N doesn't roll back batches 1 through N-1 +- Lock duration is bounded to the batch size +- Memory stays constant regardless of total row count + +### Rules for backfill tasks + +1. **One RLS transaction per batch.** Never wrap the entire backfill in a single transaction. Each batch gets its own `rls_transaction(tenant_id)`. + +2. **Use `bulk_create` / `bulk_update` with explicit `batch_size`.** Never `.save()` in a loop. The default batch_size is 500. + +3. **Use `.iterator()` for reads.** When reading source data, use `queryset.iterator()` to avoid loading the entire result set into memory. + +4. **Use `.only()` / `.values_list()` for reads.** Fetch only the columns you need, not full model instances. + +5. **Catch and skip per-item failures.** Don't let one bad row kill the entire backfill. Log the error, count it, continue. + +```python +scans_processed = 0 +scans_skipped = 0 + +for scan_id in scan_ids: + try: + result = process_scan(tenant_id, scan_id) + scans_processed += 1 + except Exception: + logger.warning("Failed to process scan %s", scan_id) + scans_skipped += 1 + +logger.info("Backfill done: %d processed, %d skipped", scans_processed, scans_skipped) +``` + +6. **Log totals at start and end, not per-batch.** Per-batch logging floods the logs. Log the total count at the start, and the processed/skipped counts at the end. + +7. **Use `ignore_conflicts=True` for idempotent creates.** Makes the backfill safe to re-run if interrupted. + +```python +Model.objects.bulk_create(objects, batch_size=500, ignore_conflicts=True) +``` + +8. **Iterate per-tenant.** Dispatch one Celery task per tenant. This gives you natural parallelism, bounded memory per task, and the ability to retry a single tenant without re-running everything. + +### Existing examples + +| Migration | Task | +|---|---| +| `0062_backfill_daily_severity_summaries.py` | `backfill_daily_severity_summaries_task` | +| `0080_backfill_attack_paths_graph_data_ready.py` | Inline (trivial update) | +| `0082_backfill_finding_group_summaries.py` | `backfill_finding_group_summaries_task` | + +Task implementations: `tasks/jobs/backfill.py` +Batch utilities: `api/db_utils.py` (`batch_delete`, `create_objects_in_batches`, `update_objects_in_batches`) + +## Decision tree + +``` +Auto-generated migration? +├── Yes → Split it following the rules below +└── No → Review it against the rules below + +New model? +├── Yes → CreateModel + AddConstraint in one migration +│ AddIndex in separate migration(s), one per table +└── No, just indexes? +│ ├── Regular table → AddIndex in its own migration +│ └── Partitioned table (findings, resource_finding_mappings)? +│ ├── Step 1: RunPython + create_index_on_partitions (atomic=False) +│ └── Step 2: AddIndex on parent (separate migration) +│ └── Large table? → Consider --fake + manual apply +└── Data backfill? + ├── Trivial update (< ~10K rows)? → Inline RunPython is OK + └── Large backfill? → Migration dispatches Celery task(s) + ├── One task per tenant + ├── Batch processing (bulk_create/bulk_update, batch_size=500) + ├── One rls_transaction per batch + └── Catch + skip per-item failures, log totals +``` + +## Quick reference + +| Scenario | Approach | +|---|---| +| Auto-generated migration | Split by concern and table before committing | +| New model + constraints/RLS | Same migration (constraints are structural) | +| Indexes on a regular table | Separate migration, one table per file | +| Indexes on a partitioned table | Two migrations: partitions first (`RunPython` + `atomic=False`), then parent (`AddIndex`) | +| Index on a huge partitioned table | Same two migrations, but fake + apply manually in production | +| Trivial data backfill (< ~10K rows) | Inline `RunPython` with single `.update()` call | +| Large data backfill | Migration dispatches Celery task per tenant, task batches with `rls_transaction` | + +## Review output format + +1. List each violation with rule number and one-line explanation +2. Show corrected migration file(s) +3. For partitioned tables, show both partition and parent migrations + +If migration passes all checks, say so. + +## Context7 lookups + +**Prerequisite:** Install Context7 MCP server for up-to-date documentation lookup. + +When implementing or debugging migration patterns, query these libraries via `mcp_context7_query-docs`: + +| Library | Context7 ID | Use for | +|---------|-------------|---------| +| Django 5.1 | `/websites/djangoproject_en_5_1` | Migration operations, indexes, constraints, `SchemaEditor` | +| PostgreSQL | `/websites/postgresql_org_docs_current` | `CREATE INDEX CONCURRENTLY`, partitioned tables, `pg_inherits` | +| django-postgres-extra | `/SectorLabs/django-postgres-extra` | Partitioned models, `PostgresPartitionedModel`, partition management | + +**Example queries:** +``` +mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_1", query="migration operations AddIndex RunPython atomic") +mcp_context7_query-docs(libraryId="/websites/djangoproject_en_5_1", query="database indexes Meta class concurrently") +mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="CREATE INDEX CONCURRENTLY partitioned table") +mcp_context7_query-docs(libraryId="/SectorLabs/django-postgres-extra", query="partitioned model range partition index") +``` + +> **Note:** Use `mcp_context7_resolve-library-id` first if you need to find the correct library ID. + +## Commands + +```bash +# Generate migrations (ALWAYS review output before committing) +python manage.py makemigrations +python manage.py pgmakemigrations + +# Apply migrations +python manage.py migrate + +# Fake a migration (mark as applied without running) +python manage.py migrate api --fake + +# Manage partitions +python manage.py pgpartition --using admin +``` + +## Resources + +- **Partition helpers**: `api/src/backend/api/db_utils.py` (`create_index_on_partitions`, `drop_index_on_partitions`) +- **Partition config**: `api/src/backend/api/partitions.py` +- **RLS constraints**: `api/src/backend/api/rls.py` +- **Existing examples**: `0028` + `0029`, `0024` + `0025`, `0036` + `0037` diff --git a/skills/postgresql-indexing/SKILL.md b/skills/postgresql-indexing/SKILL.md new file mode 100644 index 0000000000..7fac9f4ecd --- /dev/null +++ b/skills/postgresql-indexing/SKILL.md @@ -0,0 +1,392 @@ +--- +name: postgresql-indexing +description: > + PostgreSQL indexing best practices for Prowler: index design, partial indexes, partitioned table + indexing, EXPLAIN ANALYZE validation, concurrent operations, monitoring, and maintenance. + Trigger: When creating or modifying PostgreSQL indexes, analyzing query performance with EXPLAIN, + debugging slow queries, reviewing index usage statistics, reindexing, dropping indexes, or working + with partitioned table indexes. Also trigger when discussing index strategies, partial indexes, + or index maintenance operations like VACUUM or ANALYZE. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [api] + auto_invoke: + - "Creating or modifying PostgreSQL indexes" + - "Analyzing query performance with EXPLAIN" + - "Debugging slow queries or missing indexes" + - "Dropping or reindexing PostgreSQL indexes" +allowed-tools: Read, Grep, Glob, Bash +--- + +## When to use + +- Creating or modifying PostgreSQL indexes +- Analyzing query plans with `EXPLAIN` +- Debugging slow queries or missing index usage +- Dropping, reindexing, or validating indexes +- Working with indexes on partitioned tables (findings, resource_finding_mappings) +- Running VACUUM or ANALYZE after index changes + +## Index design + +### Partial indexes: constant columns go in WHERE, not in the key + +When a column has a fixed value for the query (e.g., `state = 'completed'`), put it in the `WHERE` clause of the index, not in the indexed columns. Otherwise the planner cannot exploit the ordering of the other columns. + +```sql +-- Bad: state in the key wastes space and breaks ordering +CREATE INDEX idx_scans_tenant_state ON scans (tenant_id, state, inserted_at DESC); + +-- Good: state as a filter, planner uses tenant_id + inserted_at ordering +CREATE INDEX idx_scans_tenant_ins_completed ON scans (tenant_id, inserted_at DESC) + WHERE state = 'completed'; +``` + +### Column order matters + +Put high-selectivity columns first (columns that filter out the most rows). For composite indexes, the leftmost column must appear in the query's WHERE clause for the index to be used. + +## Validating index effectiveness + +### Always EXPLAIN (ANALYZE, BUFFERS) after adding indexes + +Never assume an index is being used. Run `EXPLAIN (ANALYZE, BUFFERS)` to confirm. + +```sql +EXPLAIN (ANALYZE, BUFFERS) +SELECT * +FROM users +WHERE email = 'user@example.com'; +``` + +Use [Postgres EXPLAIN Visualizer (pev)](https://tatiyants.com/pev/) to visualize query plans and identify bottlenecks. + +### Force index usage for testing + +The planner may choose a sequential scan on small datasets. Toggle `enable_seqscan = off` to confirm the index path works, then re-enable it. + +```sql +SET enable_seqscan = off; + +EXPLAIN (ANALYZE, BUFFERS) +SELECT DISTINCT ON (provider_id) provider_id +FROM scans +WHERE tenant_id = '95383b24-da01-44b5-a713-0d9920d554db' + AND state = 'completed' +ORDER BY provider_id, inserted_at DESC; + +SET enable_seqscan = on; -- always re-enable after testing +``` + +This is for validation only. Never leave `enable_seqscan = off` in production. + +## Over-indexing + +Every extra index has three costs that compound: + +1. **Write overhead.** Every INSERT and UPDATE must maintain all indexes. Extra indexes also kill HOT (Heap-Only-Tuple) updates, which normally skip index maintenance when unindexed columns change. + +2. **Planning time.** The planner evaluates more execution paths per index. On simple OLTP queries, planning time can exceed execution time by 4x when index count is high. + +3. **Lock contention (fastpath limit).** PostgreSQL uses a fast path for the first 16 locks per backend. After 16 relations (table + its indexes), it falls back to slower LWLock mechanisms. At high QPS (100+), this causes `LockManager` wait events. + +Rules: +- Drop unused and redundant indexes regularly +- Be especially careful with partitioned tables (each partition multiplies the index count) +- Use prepared statements to reduce planning overhead when index count is high + +## Finding redundant indexes + +Two indexes are redundant when: +- They have the same columns in the same order (duplicates) +- One is a prefix of the other: index `(a)` is redundant to `(a, b)`, but NOT to `(b, a)` + +Column order matters. For partial indexes, the WHERE clause must also match. + +```sql +-- Quick check: find indexes that share a leading column on the same table +SELECT + a.indrelid::regclass AS table_name, + a.indexrelid::regclass AS index_a, + b.indexrelid::regclass AS index_b, + pg_size_pretty(pg_relation_size(a.indexrelid)) AS size_a, + pg_size_pretty(pg_relation_size(b.indexrelid)) AS size_b +FROM pg_index a +JOIN pg_index b ON a.indrelid = b.indrelid + AND a.indexrelid != b.indexrelid + AND a.indkey::text = ( + SELECT string_agg(x::text, ' ') + FROM unnest(b.indkey[:array_length(a.indkey, 1)]) AS x + ) +WHERE NOT a.indisunique; +``` + +Before dropping: verify on all workload nodes (primary + replicas), use `DROP INDEX CONCURRENTLY`, and monitor for plan regressions. + +## Monitoring index usage + +### Identify unused indexes + +Query `pg_stat_all_indexes` to find indexes that are never or rarely scanned: + +```sql +SELECT + idxstat.schemaname AS schema_name, + idxstat.relname AS table_name, + idxstat.indexrelname AS index_name, + idxstat.idx_scan AS index_scans_count, + idxstat.last_idx_scan AS last_idx_scan_timestamp, + pg_size_pretty(pg_relation_size(idxstat.indexrelid)) AS index_size +FROM pg_stat_all_indexes AS idxstat +JOIN pg_index i ON idxstat.indexrelid = i.indexrelid +WHERE idxstat.schemaname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND NOT i.indisunique +ORDER BY idxstat.idx_scan ASC, idxstat.last_idx_scan ASC; +``` + +Indexes with `idx_scan = 0` and no recent `last_idx_scan` are candidates for removal. + +Before dropping, verify: +- Stats haven't been reset recently (check `stats_reset` in `pg_stat_database`) +- Stats cover at least 1 month of production traffic +- All workload nodes (primary + replicas) have been checked +- The index isn't used by a periodic job that runs infrequently + +```sql +-- Check when stats were last reset +SELECT stats_reset, age(now(), stats_reset) +FROM pg_stat_database +WHERE datname = current_database(); +``` + +### Monitor index creation progress + +Do not assume index creation succeeded. Use `pg_stat_progress_create_index` (Postgres 12+) to watch progress live: + +```sql +SELECT * FROM pg_stat_progress_create_index; +``` + +In psql, use `\watch 5` to refresh every 5 seconds for a live dashboard view. `CREATE INDEX CONCURRENTLY` and `REINDEX CONCURRENTLY` have more phases than standard operations: monitor for blocking sessions and wait events. + +### Validate index integrity + +Check for invalid indexes regularly: + +```sql +SELECT c.relname AS index_name, i.indisvalid +FROM pg_class c +JOIN pg_index i ON i.indexrelid = c.oid +WHERE i.indisvalid = false; +``` + +Invalid indexes are ignored by the planner. They waste space and cause inconsistent query performance, especially on partitioned tables where some partitions may have valid indexes and others do not. + +## Concurrent operations + +### Always use CONCURRENTLY in production + +Never create or drop indexes without `CONCURRENTLY` on live tables. Without it, the operation holds a lock that blocks all writes. + +```sql +-- Create +CREATE INDEX CONCURRENTLY IF NOT EXISTS index_name ON table_name (column_name); + +-- Drop +DROP INDEX CONCURRENTLY IF EXISTS index_name; +``` + +`DROP INDEX CONCURRENTLY` cannot run inside a transaction block. + +### Always use IF NOT EXISTS / IF EXISTS + +Makes scripts idempotent. Safe to re-run without errors from duplicate or missing indexes. + +### Concurrent indexing can fail silently + +`CREATE INDEX CONCURRENTLY` can fail without raising an error. The result is an invalid index that the planner ignores. This is particularly dangerous on partitioned tables: some partitions get valid indexes, others don't, causing inconsistent query performance. + +After any concurrent index creation, always validate: + +```sql +SELECT c.relname, i.indisvalid +FROM pg_class c +JOIN pg_index i ON i.indexrelid = c.oid +WHERE c.relname LIKE '%your_index_name%'; +``` + +## Reindexing invalid indexes + +Rebuild invalid indexes without locking writes: + +```sql +REINDEX INDEX CONCURRENTLY index_name; +``` + +### Understanding _ccnew and _ccold artifacts + +When `CREATE INDEX CONCURRENTLY` or `REINDEX INDEX CONCURRENTLY` is interrupted, temporary indexes may remain: + +| Suffix | Meaning | Action | +|--------|---------|--------| +| `_ccnew` | New index being built, incomplete | Drop it and retry `REINDEX CONCURRENTLY` | +| `_ccold` | Old index being replaced, rebuild succeeded | Safe to drop | + +```sql +-- Example: both original and temp are invalid +-- users_emails_2019 btree (col) INVALID +-- users_emails_2019_ccnew btree (col) INVALID + +-- Drop the failed new one, then retry +DROP INDEX CONCURRENTLY IF EXISTS users_emails_2019_ccnew; +REINDEX INDEX CONCURRENTLY users_emails_2019; +``` + +These leftovers clutter the schema, confuse developers, and waste disk space. Clean them up. + +## Indexing partitioned tables + +### Do NOT use ALTER INDEX ATTACH PARTITION + +As stated in PostgreSQL documentation, `ALTER INDEX ... ATTACH PARTITION` prevents dropping malfunctioning or non-performant indexes from individual partitions. An attached index cannot be dropped by itself and is automatically dropped if its parent index is dropped. + +This removes the ability to manage indexes per-partition, which we need for: +- Dropping broken indexes on specific partitions +- Skipping indexes on old partitions to save storage +- Rebuilding indexes on individual partitions without affecting others + +### Correct approach: create on partitions, then on parent + +1. Create the index on each child partition concurrently: + +```sql +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_child_partition + ON child_partition (column_name); +``` + +2. Create the index on the parent table (metadata-only, fast): + +```sql +CREATE INDEX IF NOT EXISTS idx_parent + ON parent_table (column_name); +``` + +PostgreSQL will automatically recognize partition-level indexes as part of the parent index definition when the index names and definitions match. + +### Prioritize active partitions + +For time-based partitions (findings uses monthly partitions): + +- Create indexes on recent/current partitions where data is actively queried +- Skip older partitions that are rarely accessed +- The `all_partitions=False` default in `create_index_on_partitions` handles this automatically + +## Index maintenance and bloat + +Over time, B-tree indexes accumulate bloat from updates and deletes. VACUUM reclaims heap space but does NOT rebalance B-tree pages. Periodic reindexing is necessary for heavily updated tables. + +### Detecting bloat + +Indexes with estimated bloat above 50% are candidates for `REINDEX CONCURRENTLY`. Check bloat with tools like `pgstattuple` or bloat estimation queries. + +### Reducing bloat buildup + +Three things slow degradation: +1. **Upgrade to PostgreSQL 14+** for B-tree deduplication and bottom-up deletion +2. **Maximize HOT updates** by not indexing frequently-updated columns +3. **Tune autovacuum** to run more aggressively on high-churn tables + +### Rebuilding many indexes without deadlocks + +If you rebuild two indexes on the same table in parallel, PostgreSQL detects a deadlock and kills one session. To rebuild many indexes across multiple sessions safely, assign all indexes for a given table to the same session: + +```sql +\set NUMBER_OF_SESSIONS 10 + +SELECT + format('%I.%I', n.nspname, c.relname) AS table_fqn, + format('%I.%I', n.nspname, i.relname) AS index_fqn, + mod( + hashtext(format('%I.%I', n.nspname, c.relname)) & 2147483647, + :NUMBER_OF_SESSIONS + ) AS session_id +FROM pg_index idx +JOIN pg_class c ON idx.indrelid = c.oid +JOIN pg_class i ON idx.indexrelid = i.oid +JOIN pg_namespace n ON c.relnamespace = n.oid +WHERE n.nspname NOT IN ('pg_catalog', 'pg_toast', 'information_schema') +ORDER BY table_fqn, index_fqn; +``` + +Then run each session's indexes in a separate `REINDEX INDEX CONCURRENTLY` call. Set `NUMBER_OF_SESSIONS` based on `max_parallel_maintenance_workers` and available I/O. + +## Dropping indexes + +### Post-drop maintenance + +After dropping an index, run VACUUM and ANALYZE to reclaim space and update planner statistics: + +```sql +-- Full vacuum + analyze (can be heavy on large tables) +VACUUM (ANALYZE) your_table; + +-- Lightweight alternative for huge tables: just update statistics +ANALYZE your_table; +``` + +## Commands + +```sql +-- Validate query uses an index +EXPLAIN (ANALYZE, BUFFERS) SELECT ...; + +-- Check index creation progress +SELECT * FROM pg_stat_progress_create_index; + +-- Find invalid indexes +SELECT c.relname, i.indisvalid +FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid +WHERE i.indisvalid = false; + +-- Find unused indexes +SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) +FROM pg_stat_all_indexes +WHERE schemaname = 'public' AND idx_scan = 0; + +-- Create index safely +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_name ON table (columns); + +-- Drop index safely +DROP INDEX CONCURRENTLY IF EXISTS idx_name; + +-- Rebuild invalid index +REINDEX INDEX CONCURRENTLY idx_name; + +-- Post-drop maintenance +VACUUM (ANALYZE) table_name; +``` + +## Context7 lookups + +**Prerequisite:** Install Context7 MCP server for up-to-date documentation lookup. + +| Library | Context7 ID | Use for | +|---------|-------------|---------| +| PostgreSQL | `/websites/postgresql_org_docs_current` | Index types, EXPLAIN, partitioned table indexing, REINDEX | + +**Example queries:** +``` +mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="CREATE INDEX CONCURRENTLY partitioned table") +mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="EXPLAIN ANALYZE BUFFERS query plan") +mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="partial index WHERE clause") +mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="REINDEX CONCURRENTLY invalid index") +mcp_context7_query-docs(libraryId="/websites/postgresql_org_docs_current", query="pg_stat_all_indexes monitoring") +``` + +> **Note:** Use `mcp_context7_resolve-library-id` first if you need to find the correct library ID. + +## Resources + +- **EXPLAIN Visualizer**: [pev](https://tatiyants.com/pev/) diff --git a/skills/prowler-api/assets/celery_patterns.py b/skills/prowler-api/assets/celery_patterns.py index 700e63891b..ee1b09bfa6 100644 --- a/skills/prowler-api/assets/celery_patterns.py +++ b/skills/prowler-api/assets/celery_patterns.py @@ -249,7 +249,12 @@ celery_app.conf.update( # Django settings (config/settings/celery.py) """ -CELERY_BROKER_URL = f"redis://{VALKEY_HOST}:{VALKEY_PORT}/{VALKEY_DB}" +VALKEY_SCHEME = env("VALKEY_SCHEME", default="redis") +VALKEY_USERNAME = env("VALKEY_USERNAME", default="") +VALKEY_PASSWORD = env("VALKEY_PASSWORD", default="") +CELERY_BROKER_URL = _build_celery_broker_url( + VALKEY_SCHEME, VALKEY_USERNAME, VALKEY_PASSWORD, VALKEY_HOST, VALKEY_PORT, VALKEY_DB +) CELERY_RESULT_BACKEND = "django-db" # Store results in PostgreSQL CELERY_TASK_TRACK_STARTED = True # Track when tasks start CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True diff --git a/skills/prowler-api/references/configuration.md b/skills/prowler-api/references/configuration.md index dac5c735bf..677f999cc4 100644 --- a/skills/prowler-api/references/configuration.md +++ b/skills/prowler-api/references/configuration.md @@ -169,11 +169,21 @@ DATABASES = { ### Broker/Backend ```python +VALKEY_SCHEME = env("VALKEY_SCHEME", default="redis") +VALKEY_USERNAME = env("VALKEY_USERNAME", default="") +VALKEY_PASSWORD = env("VALKEY_PASSWORD", default="") VALKEY_HOST = env("VALKEY_HOST", default="valkey") VALKEY_PORT = env("VALKEY_PORT", default="6379") VALKEY_DB = env("VALKEY_DB", default="0") -CELERY_BROKER_URL = f"redis://{VALKEY_HOST}:{VALKEY_PORT}/{VALKEY_DB}" +CELERY_BROKER_URL = _build_celery_broker_url( + VALKEY_SCHEME, + VALKEY_USERNAME, + VALKEY_PASSWORD, + VALKEY_HOST, + VALKEY_PORT, + VALKEY_DB, +) CELERY_RESULT_BACKEND = "django-db" # Store results in PostgreSQL CELERY_TASK_TRACK_STARTED = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True diff --git a/skills/prowler-api/references/production-settings.md b/skills/prowler-api/references/production-settings.md index ea9b59c328..12b64483bf 100644 --- a/skills/prowler-api/references/production-settings.md +++ b/skills/prowler-api/references/production-settings.md @@ -164,8 +164,13 @@ POSTGRES_DB=... POSTGRES_USER=... POSTGRES_PASSWORD=... -# Redis (for Celery) -REDIS_URL=redis://host:6379/0 +# Valkey/Redis (for Celery) +VALKEY_SCHEME=rediss +VALKEY_USERNAME=default +VALKEY_PASSWORD= +VALKEY_HOST=host +VALKEY_PORT=6379 +VALKEY_DB=0 # Optional SENTRY_DSN=https://...@sentry.io/... diff --git a/skills/prowler-attack-paths-query/SKILL.md b/skills/prowler-attack-paths-query/SKILL.md index 35fb9341d3..fb25d4fa43 100644 --- a/skills/prowler-attack-paths-query/SKILL.md +++ b/skills/prowler-attack-paths-query/SKILL.md @@ -1,13 +1,14 @@ --- name: prowler-attack-paths-query description: > - Creates Prowler Attack Paths openCypher queries for graph analysis (compatible with Neo4j and Neptune). - Trigger: When creating or updating Attack Paths queries that detect privilege escalation paths, - network exposure, or security misconfigurations in cloud environments. + 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. + Trigger: When creating or updating Attack Paths queries. license: Apache-2.0 metadata: author: prowler-cloud - version: "1.0" + version: "2.0" scope: [root, api] auto_invoke: - "Creating Attack Paths queries" @@ -20,7 +21,24 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, Task 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** to ensure compatibility with both Neo4j and Amazon Neptune. +Queries are written in **openCypher Version 9** for compatibility with both Neo4j and Amazon Neptune. + +--- + +## 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/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) | + +**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. --- @@ -29,58 +47,44 @@ Queries are written in **openCypher Version 9** to ensure compatibility with bot Queries can be created from: 1. **pathfinding.cloud ID** (e.g., `ECS-001`, `GLUE-001`) - - The JSON index contains: `id`, `name`, `description`, `services`, `permissions`, `exploitationSteps`, `prerequisites`, etc. - Reference: https://github.com/DataDog/pathfinding.cloud - - **Fetching a single path by ID** - The aggregated `paths.json` is too large for WebFetch - (content gets truncated). Use Bash with `curl` and a JSON parser instead: - - Prefer `jq` (concise), fall back to `python3` (guaranteed in this Python project): + - The aggregated `paths.json` is too large for WebFetch. Use Bash: ```bash - # With jq + # Fetch a single path by ID curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ | jq '.[] | select(.id == "ecs-002")' - # With python3 (fallback) - curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ - | python3 -c "import json,sys; print(json.dumps(next((p for p in json.load(sys.stdin) if p['id']=='ecs-002'), None), indent=2))" - ``` - -2. **Listing Available Attack Paths** - - Use Bash to list available paths from the JSON index: - - ```bash - # List all path IDs and names (jq) + # List all path IDs and names curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ | jq -r '.[] | "\(.id): \(.name)"' - # List all path IDs and names (python3 fallback) - curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ - | python3 -c "import json,sys; [print(f\"{p['id']}: {p['name']}\") for p in json.load(sys.stdin)]" - - # List paths filtered by service prefix + # Filter by service prefix curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \ | jq -r '.[] | select(.id | startswith("ecs")) | "\(.id): \(.name)"' ``` -3. **Natural Language Description** - - User describes the Attack Paths in plain language - - Agent maps to appropriate openCypher patterns + If `jq` is not available, use `python3 -c "import json,sys; ..."` as a fallback. + +2. **Natural language description** from the user --- ## Query Structure -### File Location +### Provider scoping parameter -``` -api/src/backend/api/attack_paths/queries/{provider}.py -``` +One parameter is injected automatically by the query runner: -Example: `api/src/backend/api/attack_paths/queries/aws.py` +| Parameter | Property it matches | Used on | Purpose | +| --------------- | ------------------- | ------------ | -------------------------------- | +| `$provider_uid` | `id` | `AWSAccount` | Scopes to a specific AWS account | -### Query Definition Pattern +All other nodes are isolated by path connectivity from the `AWSAccount` anchor. + +### Imports + +All query files start with these imports: ```python from api.attack_paths.queries.types import ( @@ -89,56 +93,123 @@ from api.attack_paths.queries.types import ( AttackPathsQueryParameterDefinition, ) from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL +``` -# {REFERENCE_ID} (e.g., EC2-001, GLUE-001) +The `PROWLER_FINDING_LABEL` constant (value: `"ProwlerFinding"`) is used via f-string interpolation in all queries. Never hardcode the label string. + +### Privilege escalation sub-patterns + +There are four distinct privilege escalation patterns. Choose based on the attack type: + +| 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 | + +#### Self-escalation (e.g., IAM-001) + +The principal modifies resources attached to itself. `path_target` loops back to `principal`: + +```python AWS_{QUERY_NAME} = AttackPathsQueryDefinition( id="aws-{kebab-case-name}", name="{Human-friendly label} ({REFERENCE_ID})", - short_description="{Brief explanation of the attack, no technical permissions.}", + short_description="{Brief explanation, no technical permissions.}", description="{Detailed description of the attack vector and impact.}", attribution=AttackPathsQueryAttribution( - text="pathfinding.cloud - {REFERENCE_ID} - {permission1} + {permission2}", + text="pathfinding.cloud - {REFERENCE_ID} - {permission}", link="https://pathfinding.cloud/paths/{reference_id_lowercase}", ), provider="aws", cypher=f""" - // Find principals with {permission1} + // 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) = '{permission1_lowercase}' + toLower(action) = '{permission_lowercase}' OR toLower(action) = '{service}:*' OR action = '*' ) - // Find {permission2} - MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement) - WHERE stmt2.effect = 'Allow' - AND any(action IN stmt2.action WHERE - toLower(action) = '{permission2_lowercase}' - OR toLower(action) = '{service2}:*' - OR action = '*' + // Find target resources attached to the same principal + 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 ) - // Find target resources (MUST chain from `aws` for provider isolation) - 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 - ) + WITH collect(path_principal) + collect(path_target) AS paths + UNWIND paths AS p + UNWIND nodes(p) AS n - UNWIND nodes(path_principal) + nodes(path_target) as n - OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL', provider_uid: $provider_uid}}) + WITH paths, collect(DISTINCT n) AS unique_nodes + UNWIND unique_nodes AS n + OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}}) - RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr + RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr """, parameters=[], ) ``` -### Register in Query List +#### Other sub-pattern `path_target` shapes + +The other 3 sub-patterns share the same `path_principal`, deduplication tail, and RETURN as self-escalation. Only the `path_target` MATCH differs: + +```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) +``` + +**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. + +### Network exposure pattern + +The Internet node is reached via `CAN_ACCESS` through the already-scoped resource, not via 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 + + // 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 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=[], +) +``` + +### Register in query list Add to the `{PROVIDER}_QUERIES` list at the bottom of the file: @@ -151,11 +222,11 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [ --- -## Step-by-Step Creation Process +## Step-by-step creation process -### 1. Read the Queries Module +### 1. Read the queries module -**FIRST**, read all files in the queries module to understand the structure: +**FIRST**, read all files in the queries module to understand the structure, type definitions, registration, and existing style: ``` api/src/backend/api/attack_paths/queries/ @@ -165,93 +236,50 @@ api/src/backend/api/attack_paths/queries/ └── {provider}.py # Provider-specific queries (e.g., aws.py) ``` -Read these files to learn: +**DO NOT** use generic templates. Match the exact style of existing queries in the file. -- Type definitions and available fields -- How queries are registered -- Current query patterns, style, and naming conventions +### 2. Fetch and consult the Cartography schema -### 2. Determine Schema Source +**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. -Check the Cartography dependency in `api/pyproject.toml`: +Check `api/pyproject.toml` for the Cartography dependency, then fetch the schema: ```bash grep cartography api/pyproject.toml ``` -Parse the dependency to determine the schema source: - -**If git-based dependency** (e.g., `cartography @ git+https://github.com/prowler-cloud/cartography@0.126.1`): - -- Extract the repository (e.g., `prowler-cloud/cartography`) -- Extract the version/tag (e.g., `0.126.1`) -- Fetch schema from that repository at that tag - -**If PyPI dependency** (e.g., `cartography = "^0.126.0"` or `cartography>=0.126.0`): - -- Extract the version (e.g., `0.126.0`) -- Use the official `cartography-cncf` repository - -**Schema URL patterns** (ALWAYS use the specific version tag, not master/main): +Build the schema URL (ALWAYS use the specific tag, not master/main): ``` -# Official Cartography (cartography-cncf) -https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md +# 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 -# Prowler fork (prowler-cloud) -https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md +# PyPI dependency (cartography = "^0.126.0"): +https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.126.0/docs/root/modules/{provider}/schema.md ``` -**Examples**: +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. -```bash -# For prowler-cloud/cartography@0.126.1 (git), fetch AWS schema: -https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/0.126.1/docs/root/modules/aws/schema.md +### 4. Create query definition -# For cartography = "^0.126.0" (PyPI), fetch AWS schema: -https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.126.0/docs/root/modules/aws/schema.md -``` +Use the appropriate pattern (privilege escalation or network exposure) with: -**IMPORTANT**: Always match the schema version to the dependency version in `pyproject.toml`. Using master/main may reference node labels or properties that don't exist in the deployed version. - -**Additional Prowler Labels**: The Attack Paths sync task adds extra labels: - -- `ProwlerFinding` - Prowler finding nodes with `status`, `provider_uid` properties -- `ProviderResource` - Generic resource marker -- `{Provider}Resource` - Provider-specific marker (e.g., `AWSResource`) - -These are defined in `api/src/backend/tasks/jobs/attack_paths/config.py`. - -### 3. Consult the Schema for Available Data - -Use the Cartography schema to discover: - -- What node labels exist for the target resources -- What properties are available on those nodes -- What relationships connect the nodes - -This informs query design by showing what data is actually available to query. - -### 4. Create Query Definition - -Use the standard pattern (see above) with: - -- **id**: Auto-generated as `{provider}-{kebab-case-description}` -- **name**: Short, human-friendly label. No raw IAM permissions. For sourced queries (e.g., pathfinding.cloud), append the reference ID in parentheses: `"EC2 Instance Launch with Privileged Role (EC2-001)"`. If the name already has parentheses, prepend the ID inside them: `"ECS Service Creation with Privileged Role (ECS-003 - Existing Cluster)"`. -- **short_description**: Brief explanation of the attack, no technical permissions. E.g., "Launch EC2 instances with privileged IAM roles to gain their permissions via IMDS." -- **description**: Full technical explanation of the attack vector and impact. Plain text only, no HTML or technical permissions here. +- **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 (use `parameters=[]` if none needed) -- **attribution**: Optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `text` includes the source, reference ID, and technical permissions (e.g., `"pathfinding.cloud - EC2-001 - iam:PassRole + ec2:RunInstances"`). The `link` is the URL with a lowercase ID (e.g., `"https://pathfinding.cloud/paths/ec2-001"`). Omit (defaults to `None`) for non-sourced queries. +- **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 +### 5. Add query to provider list Add the constant to the `{PROVIDER}_QUERIES` list. --- -## Query Naming Conventions +## Query naming conventions ### Query ID @@ -259,27 +287,19 @@ Add the constant to the `{PROVIDER}_QUERIES` list. {provider}-{category}-{description} ``` -Examples: +Examples: `aws-ec2-privesc-passrole-iam`, `aws-ec2-instances-internet-exposed` -- `aws-ec2-privesc-passrole-iam` -- `aws-iam-privesc-attach-role-policy-assume-role` -- `aws-rds-unencrypted-storage` - -### Query Constant Name +### Query constant name ``` {PROVIDER}_{CATEGORY}_{DESCRIPTION} ``` -Examples: - -- `AWS_EC2_PRIVESC_PASSROLE_IAM` -- `AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_ASSUME_ROLE` -- `AWS_RDS_UNENCRYPTED_STORAGE` +Examples: `AWS_EC2_PRIVESC_PASSROLE_IAM`, `AWS_EC2_INSTANCES_INTERNET_EXPOSED` --- -## Query Categories +## Query categories | Category | Description | Example | | -------------------- | ------------------------------ | ------------------------- | @@ -290,15 +310,15 @@ Examples: --- -## Common openCypher Patterns +## Common openCypher patterns -### Match Account and Principal +### Match account and principal ```cypher MATCH path_principal = (aws:AWSAccount {id: $provider_uid})--(principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement) ``` -### Check IAM Action Permissions +### Check IAM action permissions ```cypher WHERE stmt.effect = 'Allow' @@ -309,13 +329,21 @@ WHERE stmt.effect = 'Allow' ) ``` -### Find Roles Trusting a Service +### Find roles trusting a service ```cypher MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'ec2.amazonaws.com'}) ``` -### Check Resource Scope +### Find roles the principal can assume + +Note the arrow direction - `STS_ASSUMEROLE_ALLOW` points from the role to the principal: + +```cypher +MATCH path_target = (aws)--(target_role:AWSRole)<-[:STS_ASSUMEROLE_ALLOW]-(principal) +``` + +### Check resource scope ```cypher WHERE any(resource IN stmt.resource WHERE @@ -325,173 +353,135 @@ WHERE any(resource IN stmt.resource WHERE ) ``` -### Include Prowler Findings +### Internet node via path connectivity + +The Internet node is reached through `CAN_ACCESS` relationships to already-scoped resources. No standalone lookup needed: ```cypher -UNWIND nodes(path_principal) + nodes(path_target) as n -OPTIONAL MATCH (n)-[pfr]-(pf:{PROWLER_FINDING_LABEL} {status: 'FAIL', provider_uid: $provider_uid}) +OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource) +``` -RETURN path_principal, path_target, - collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr +### Multi-label OR (match multiple resource types) + +```cypher +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) +``` + +### Include Prowler findings + +Deduplicate nodes before the ProwlerFinding lookup to avoid redundant OPTIONAL MATCH calls on nodes that appear in multiple paths: + +```cypher +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 +``` + +For network exposure queries, aggregate the internet node and relationship alongside paths: + +```cypher +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 ``` --- -## Common Node Labels by Provider +## Prowler-specific labels and relationships -### AWS +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). -| Label | Description | -| -------------------- | ----------------------------------- | -| `AWSAccount` | AWS account root | -| `AWSPrincipal` | IAM principal (user, role, service) | -| `AWSRole` | IAM role | -| `AWSUser` | IAM user | -| `AWSPolicy` | IAM policy | -| `AWSPolicyStatement` | Policy statement | -| `EC2Instance` | EC2 instance | -| `EC2SecurityGroup` | Security group | -| `S3Bucket` | S3 bucket | -| `RDSInstance` | RDS database instance | -| `LoadBalancer` | Classic ELB | -| `LoadBalancerV2` | ALB/NLB | -| `LaunchTemplate` | EC2 launch template | - -### Common Relationships - -| Relationship | Description | -| ---------------------- | ----------------------- | -| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship | -| `STS_ASSUMEROLE_ALLOW` | Can assume role | -| `POLICY` | Has policy attached | -| `STATEMENT` | Policy has statement | +| 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) | --- ## Parameters -For queries requiring user input, define parameters: +For queries requiring user input: ```python parameters=[ AttackPathsQueryParameterDefinition( name="ip", label="IP address", + # data_type defaults to "string", cast defaults to str. + # For non-string params, set both: data_type="integer", cast=int description="Public IP address, e.g. 192.0.2.0.", placeholder="192.0.2.0", ), - AttackPathsQueryParameterDefinition( - name="tag_key", - label="Tag key", - description="Tag key to filter resources.", - placeholder="Environment", - ), ], ``` --- -## Best Practices +## Best practices -1. **Always filter by provider_uid**: Use `{id: $provider_uid}` on account nodes and `{provider_uid: $provider_uid}` on ProwlerFinding nodes - -2. **Use consistent naming**: Follow existing patterns in the file - -3. **Include Prowler findings**: Always add the OPTIONAL MATCH for ProwlerFinding nodes - -4. **Return distinct findings**: Use `collect(DISTINCT pf)` to avoid duplicates - -5. **Comment the query purpose**: Add inline comments explaining each MATCH clause - -6. **Validate schema first**: Ensure all node labels and properties exist in Cartography schema - -7. **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). The tenant database contains data from multiple providers — an unanchored `MATCH` would return nodes from all providers, breaking provider isolation. +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. ```cypher - // WRONG: matches ALL AWSRoles across all providers in the tenant DB + // WRONG: matches ALL AWSRoles across all providers MATCH (role:AWSRole) WHERE role.name = 'admin' // 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. + --- -## openCypher Compatibility +## openCypher compatibility -Queries must be written in **openCypher Version 9** to ensure compatibility with both Neo4j and Amazon Neptune. +Queries must be written in **openCypher Version 9** for compatibility with both Neo4j and Amazon Neptune. -> **Why Version 9?** Amazon Neptune implements openCypher Version 9. By targeting this specification, queries work on both Neo4j and Neptune without modification. +### Avoid these (not in openCypher spec) -### Avoid These (Not in openCypher spec) - -| Feature | Reason | -| --------------------------------------------------- | ----------------------------------------------- | -| APOC procedures (`apoc.*`) | Neo4j-specific plugin, not available in Neptune | -| Virtual nodes (`apoc.create.vNode`) | APOC-specific | -| Virtual relationships (`apoc.create.vRelationship`) | APOC-specific | -| Neptune extensions | Not available in Neo4j | -| `reduce()` function | Use `UNWIND` + aggregation instead | -| `FOREACH` clause | Use `WITH` + `UNWIND` + `SET` instead | -| Regex match operator (`=~`) | Not supported in Neptune | - -### CALL Subqueries - -Supported with limitations: - -- Use `WITH` clause to import variables: `CALL { WITH var ... }` -- Updates inside CALL subqueries are NOT supported -- Emitted variables cannot overlap with variables before the CALL +| 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) | --- ## Reference -### pathfinding.cloud (Attack Path Definitions) - -- **Repository**: https://github.com/DataDog/pathfinding.cloud -- **All paths JSON**: `https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json` -- Use WebFetch to query specific paths or list available services - -### Cartography Schema - -- **URL pattern**: `https://raw.githubusercontent.com/{org}/cartography/refs/tags/{version}/docs/root/modules/{provider}/schema.md` -- Always use the version from `api/pyproject.toml`, not master/main - -### openCypher Specification - -- **Neptune openCypher compliance** (what Neptune supports): https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html -- **Rewriting Cypher for Neptune** (converting Neo4j-specific syntax): https://docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html -- **openCypher project** (spec, grammar, TCK): https://github.com/opencypher/openCypher - ---- - -## Learning from the Queries Module - -**IMPORTANT**: Before creating a new query, ALWAYS read the entire queries module: - -``` -api/src/backend/api/attack_paths/queries/ -├── __init__.py # Module exports -├── types.py # Type definitions -├── registry.py # Registry logic -└── {provider}.py # Provider queries (aws.py, etc.) -``` - -Use the existing queries to learn: - -- Query structure and formatting -- Variable naming conventions -- How to include Prowler findings -- Comment style - -> **Compatibility Warning**: Some existing queries use Neo4j-specific features -> (e.g., `apoc.create.vNode`, `apoc.create.vRelationship`, regex `=~`) that are -> **NOT compatible** with Amazon Neptune. Use these queries to learn general -> patterns (structure, naming, Prowler findings integration, comment style) but -> **DO NOT copy APOC procedures or other Neo4j-specific syntax** into new queries. -> New queries must be pure openCypher Version 9. Refer to the -> [openCypher Compatibility](#opencypher-compatibility) section for the full list -> of features to avoid. - -**DO NOT** use generic templates. Match the exact style of existing **compatible** queries in the file. +- **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 diff --git a/skills/prowler-pr/SKILL.md b/skills/prowler-pr/SKILL.md index c2d87716ad..e147d37fbe 100644 --- a/skills/prowler-pr/SKILL.md +++ b/skills/prowler-pr/SKILL.md @@ -132,6 +132,18 @@ Follow conventional commits: 4. ✅ Branch is up to date with main 5. ✅ Commits are clean and descriptive +## Before Re-Requesting Review (REQUIRED) + +Resolve or respond to **every** open inline review thread before re-requesting review: + +1. **Agreed + fixed**: Commit the change. Reply with the commit hash so the reviewer can verify quickly: + > Fixed in `abc1234`. +2. **Agreed but deferred**: Explain why it's out of scope for this PR and where it's tracked. +3. **Disagreed**: Reply with clear technical reasoning. Do not leave threads silently open. +4. **Re-request review** only after all threads are in a clean state — either resolved or explicitly responded to. + +> **Rule of thumb**: A reviewer should never have to wonder "did they see my comment?" when they re-open the PR. + ## Resources - **Documentation**: See [references/](references/) for links to local developer guide diff --git a/skills/prowler-ui/SKILL.md b/skills/prowler-ui/SKILL.md index b1e68f8170..3e6407889e 100644 --- a/skills/prowler-ui/SKILL.md +++ b/skills/prowler-ui/SKILL.md @@ -186,6 +186,109 @@ cd ui && pnpm run build cd ui && pnpm start ``` +## Batch vs Instant Component API (REQUIRED) + +When a component supports both **batch** (deferred, submit-based) and **instant** (immediate callback) behavior, model the coupling with a discriminated union — never as independent optionals. Coupled props must be all-or-nothing. + +```typescript +// ❌ NEVER: Independent optionals — allows invalid half-states +interface FilterProps { + onBatchApply?: (values: string[]) => void; + onInstantChange?: (value: string) => void; + isBatchMode?: boolean; +} + +// ✅ ALWAYS: Discriminated union — one valid shape per mode +type BatchProps = { + mode: "batch"; + onApply: (values: string[]) => void; + onCancel: () => void; +}; + +type InstantProps = { + mode: "instant"; + onChange: (value: string) => void; + // onApply/onCancel are forbidden here via structural exclusion + onApply?: never; + onCancel?: never; +}; + +type FilterProps = BatchProps | InstantProps; +``` + +This makes invalid prop combinations a compile error, not a runtime surprise. + +## Reuse Shared Display Utilities First (REQUIRED) + +Before adding **local** display maps (labels, provider names, status strings, category formatters), search `ui/types/*` and `ui/lib/*` for existing helpers. + +```typescript +// ✅ CHECK THESE FIRST before creating a new map: +// ui/lib/utils.ts → general formatters +// ui/types/providers.ts → provider display names, icons +// ui/types/findings.ts → severity/status display maps +// ui/types/compliance.ts → category/group formatters + +// ❌ NEVER add a local map that already exists: +const SEVERITY_LABELS: Record = { + critical: "Critical", + high: "High", + // ...duplicating an existing shared map +}; + +// ✅ Import and reuse instead: +import { severityLabel } from "@/types/findings"; +``` + +If a helper doesn't exist and will be used in 2+ places, add it to `ui/lib/` or `ui/types/` and reuse it. Keep local only if used in exactly one place. + +## Derived State Rule (REQUIRED) + +Avoid `useState` + `useEffect` patterns that mirror props or searchParams — they create sync bugs and unnecessary re-renders. Derive values directly from the source of truth. + +```typescript +// ❌ NEVER: Mirror props into state via effect +const [localFilter, setLocalFilter] = useState(filter); +useEffect(() => { setLocalFilter(filter); }, [filter]); + +// ✅ ALWAYS: Derive directly +const localFilter = filter; // or compute inline +``` + +If local state is genuinely needed (e.g., optimistic UI, pending edits before submit), add a short comment: + +```typescript +// Local state needed: user edits are buffered until "Apply" is clicked +const [pending, setPending] = useState(initialValues); +``` + +## Strict Key Typing for Label Maps (REQUIRED) + +Avoid `Record` when the key set is known. Use an explicit union type or a const-key object so typos are caught at compile time. + +```typescript +// ❌ Loose — typos compile silently +const STATUS_LABELS: Record = { + actve: "Active", // typo, no error +}; + +// ✅ Tight — union key +type Status = "active" | "inactive" | "pending"; +const STATUS_LABELS: Record = { + active: "Active", + inactive: "Inactive", + pending: "Pending", + // actve: "Active" ← compile error +}; + +// ✅ Also fine — const satisfies +const STATUS_LABELS = { + active: "Active", + inactive: "Inactive", + pending: "Pending", +} as const satisfies Record; +``` + ## QA Checklist Before Commit - [ ] `pnpm run typecheck` passes @@ -199,6 +302,15 @@ cd ui && pnpm start - [ ] Accessibility: keyboard navigation, ARIA labels - [ ] Mobile responsive (if applicable) +## Pre-Re-Review Checklist (Review Thread Hygiene) + +Before requesting re-review from a reviewer: + +- [ ] Every unresolved inline thread has been either fixed or explicitly answered with a rationale +- [ ] If you agreed with a comment: the change is committed and the commit hash is mentioned in the reply +- [ ] If you disagreed: the reply explains why with clear reasoning — do not leave threads silently open +- [ ] Re-request review only after all threads are in a clean state + ## Migrations Reference | From | To | Key Changes | diff --git a/skills/prowler/SKILL.md b/skills/prowler/SKILL.md index 2ef6f2fb4d..667ad0cc4b 100644 --- a/skills/prowler/SKILL.md +++ b/skills/prowler/SKILL.md @@ -16,7 +16,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task | Component | Stack | Location | |-----------|-------|----------| -| SDK | Python 3.9+, Poetry | `prowler/` | +| SDK | Python 3.10+, Poetry | `prowler/` | | API | Django 5.1, DRF, Celery | `api/` | | UI | Next.js 15, React 19, Tailwind 4 | `ui/` | | MCP | FastMCP 2.13.1 | `mcp_server/` | diff --git a/skills/typescript/SKILL.md b/skills/typescript/SKILL.md index 046465a75b..4d664d3189 100644 --- a/skills/typescript/SKILL.md +++ b/skills/typescript/SKILL.md @@ -102,6 +102,38 @@ function isUser(value: unknown): value is User { } ``` +## Coupled Optional Props (REQUIRED) + +Do not model semantically coupled props as independent optionals — this allows invalid half-states that compile but break at runtime. Use discriminated unions with `never` to make invalid combinations impossible. + +```typescript +// ❌ BEFORE: Independent optionals — half-states allowed +interface PaginationProps { + onPageChange?: (page: number) => void; + pageSize?: number; + currentPage?: number; +} + +// ✅ AFTER: Discriminated union — shape is all-or-nothing +type ControlledPagination = { + controlled: true; + currentPage: number; + pageSize: number; + onPageChange: (page: number) => void; +}; + +type UncontrolledPagination = { + controlled: false; + currentPage?: never; + pageSize?: never; + onPageChange?: never; +}; + +type PaginationProps = ControlledPagination | UncontrolledPagination; +``` + +**Key rule:** If two or more props are only meaningful together, they belong to the same discriminated union branch. Mixing them as independent optionals shifts correctness responsibility from the type system to runtime guards. + ## Import Types ```typescript diff --git a/tests/config/config_test.py b/tests/config/config_test.py index f67f3fda35..08b1f7d5e0 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -31,6 +31,7 @@ old_config_aws = { "ec2_allowed_interface_types": ["api_gateway_managed", "vpc_endpoint"], "ec2_allowed_instance_owners": ["amazon-elb"], "trusted_account_ids": [], + "trusted_ips": [], "log_group_retention_days": 365, "max_idle_disconnect_timeout_in_seconds": 600, "max_disconnect_timeout_in_seconds": 300, @@ -95,6 +96,7 @@ config_aws = { "fargate_linux_latest_version": "1.4.0", "fargate_windows_latest_version": "1.0.0", "trusted_account_ids": [], + "trusted_ips": [], "log_group_retention_days": 365, "max_idle_disconnect_timeout_in_seconds": 600, "max_disconnect_timeout_in_seconds": 300, diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index 44e826d382..1b63e2387f 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -72,6 +72,11 @@ aws: # trusted_account_ids : ["123456789012", "098765432109", "678901234567"] trusted_account_ids: [] + # AWS OpenSearch Configuration (opensearch_service_domains_not_publicly_accessible) + # Trusted IP addresses or CIDR ranges that should not be considered as public access, e.g. + # trusted_ips: ["1.2.3.4", "10.0.0.0/8"] + trusted_ips: [] + # AWS Cloudwatch Configuration # aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days log_group_retention_days: 365 diff --git a/tests/config/fixtures/config_old.yaml b/tests/config/fixtures/config_old.yaml index cbd3bf4fa0..33220e1246 100644 --- a/tests/config/fixtures/config_old.yaml +++ b/tests/config/fixtures/config_old.yaml @@ -25,6 +25,11 @@ ec2_allowed_instance_owners: # trusted_account_ids : ["123456789012", "098765432109", "678901234567"] trusted_account_ids: [] +# AWS OpenSearch Configuration (opensearch_service_domains_not_publicly_accessible) +# Trusted IP addresses or CIDR ranges that should not be considered as public access, e.g. +# trusted_ips: ["1.2.3.4", "10.0.0.0/8"] +trusted_ips: [] + # AWS Cloudwatch Configuration # aws.cloudwatch_log_group_retention_policy_specific_days_enabled --> by default is 365 days log_group_retention_days: 365 diff --git a/tests/lib/check/check_loader_test.py b/tests/lib/check/check_loader_test.py index 740ed67844..5650ee17ab 100644 --- a/tests/lib/check/check_loader_test.py +++ b/tests/lib/check/check_loader_test.py @@ -31,16 +31,19 @@ class TestCheckLoader: Provider="aws", CheckID=S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME, CheckTitle="Check S3 Bucket Level Public Access Block.", - CheckType=["Data Protection"], + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], CheckAliases=[S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_CUSTOM_ALIAS], ServiceName=S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_SERVICE, SubServiceName="", ResourceIdTemplate="arn:partition:s3:::bucket_name", Severity=S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_SEVERITY, ResourceType="AwsS3Bucket", + ResourceGroup="storage", Description="Check S3 Bucket Level Public Access Block.", Risk="Public access policies may be applied to sensitive data buckets.", - RelatedUrl="https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html", + RelatedUrl="", Remediation=Remediation( Code=Code( NativeIaC="", @@ -50,7 +53,7 @@ class TestCheckLoader: ), Recommendation=Recommendation( Text="You can enable Public Access Block at the bucket level to prevent the exposure of your data stored in S3.", - Url="https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html", + Url="https://hub.prowler.com/check/s3_bucket_level_public_access_block", ), ), Categories=["internet-exposed"], @@ -65,16 +68,19 @@ class TestCheckLoader: Provider="aws", CheckID=IAM_USER_NO_MFA_NAME, CheckTitle="Check IAM User No MFA.", - CheckType=["Data Protection"], + CheckType=[ + "Software and Configuration Checks/Industry and Regulatory Standards/CIS AWS Foundations Benchmark" + ], CheckAliases=[IAM_USER_NO_MFA_NAME_CUSTOM_ALIAS], ServiceName=IAM_USER_NO_MFA_NAME_SERVICE, SubServiceName="", ResourceIdTemplate="arn:partition:iam::account-id:user/user_name", Severity=IAM_USER_NO_MFA_SEVERITY, ResourceType="AwsIamUser", + ResourceGroup="IAM", Description="Check IAM User No MFA.", Risk="IAM users should have Multi-Factor Authentication (MFA) enabled.", - RelatedUrl="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html", + RelatedUrl="", Remediation=Remediation( Code=Code( NativeIaC="", @@ -84,7 +90,7 @@ class TestCheckLoader: ), Recommendation=Recommendation( Text="You can enable MFA for your IAM user to prevent unauthorized access to your AWS account.", - Url="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html", + Url="https://hub.prowler.com/check/iam_user_no_mfa", ), ), Categories=[], @@ -98,8 +104,8 @@ class TestCheckLoader: return CheckMetadata( Provider="aws", CheckID=CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME, - CheckTitle="Ensure there are no potential enumeration threats in CloudTrail", - CheckType=[], + CheckTitle="CloudTrail should not have potential enumeration threats", + CheckType=["TTPs/Discovery"], ServiceName="cloudtrail", SubServiceName="", ResourceIdTemplate="arn:partition:service:region:account-id:resource-id", @@ -112,7 +118,7 @@ class TestCheckLoader: Code=Code(CLI="", NativeIaC="", Other="", Terraform=""), Recommendation=Recommendation( Text="To remediate this issue, ensure that there are no potential enumeration threats in CloudTrail.", - Url="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-concepts.html#cloudtrail-concepts-logging-data-events", + Url="https://hub.prowler.com/check/cloudtrail_threat_detection_enumeration", ), ), Categories=["threat-detection"], @@ -123,55 +129,55 @@ class TestCheckLoader: ) def test_load_checks_to_execute(self): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, provider=self.provider, ) def test_load_checks_to_execute_with_check_list(self): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } check_list = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME] assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, check_list=check_list, provider=self.provider, ) def test_load_checks_to_execute_with_severities(self): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } severities = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_SEVERITY] assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, severities=severities, provider=self.provider, ) def test_load_checks_to_execute_with_severities_and_services(self): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } service_list = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_SERVICE] severities = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_SEVERITY] assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, service_list=service_list, severities=severities, provider=self.provider, ) def test_load_checks_to_execute_with_severities_and_services_multiple(self): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), IAM_USER_NO_MFA_NAME: self.get_custom_check_iam_metadata(), } @@ -182,7 +188,7 @@ class TestCheckLoader: S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME, IAM_USER_NO_MFA_NAME, } == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, service_list=service_list, severities=severities, provider=self.provider, @@ -211,7 +217,7 @@ class TestCheckLoader: def test_load_checks_to_execute_with_checks_file( self, ): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } checks_file = "path/to/test_file" @@ -220,7 +226,7 @@ class TestCheckLoader: return_value={S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME}, ): assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, checks_file=checks_file, provider=self.provider, ) @@ -228,13 +234,13 @@ class TestCheckLoader: def test_load_checks_to_execute_with_service_list( self, ): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } service_list = [S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_SERVICE] assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, service_list=service_list, provider=self.provider, ) @@ -242,7 +248,7 @@ class TestCheckLoader: def test_load_checks_to_execute_with_compliance_frameworks( self, ): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } bulk_compliance_frameworks = { @@ -264,34 +270,39 @@ class TestCheckLoader: } compliance_frameworks = ["soc2_aws"] - assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, - bulk_compliance_frameworks=bulk_compliance_frameworks, - compliance_frameworks=compliance_frameworks, - provider=self.provider, - ) + # Mock get_bulk to prevent loading real metadata files that may fail validation + with patch( + "prowler.lib.check.checks_loader.CheckMetadata.get_bulk", + return_value=bulk_checks_metadata, + ): + assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + bulk_compliance_frameworks=bulk_compliance_frameworks, + compliance_frameworks=compliance_frameworks, + provider=self.provider, + ) def test_load_checks_to_execute_with_categories( self, ): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } categories = {"internet-exposed"} assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, categories=categories, provider=self.provider, ) def test_load_checks_to_execute_no_bulk_checks_metadata(self): - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } with patch( "prowler.lib.check.checks_loader.CheckMetadata.get_bulk", - return_value=bulk_checks_metatada, + return_value=bulk_checks_metadata, ): assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( provider=self.provider, @@ -318,13 +329,13 @@ class TestCheckLoader: compliance_frameworks = ["soc2_aws"] - bulk_checks_metatada = { + bulk_checks_metadata = { S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() } with ( patch( "prowler.lib.check.checks_loader.CheckMetadata.get_bulk", - return_value=bulk_checks_metatada, + return_value=bulk_checks_metadata, ), patch( "prowler.lib.check.checks_loader.Compliance.get_bulk", @@ -351,38 +362,38 @@ class TestCheckLoader: ) def test_threat_detection_category(self): - bulk_checks_metatada = { + bulk_checks_metadata = { CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata() } categories = {"threat-detection"} assert {CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, categories=categories, provider=self.provider, ) def test_discard_threat_detection_checks(self): - bulk_checks_metatada = { + bulk_checks_metadata = { CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata() } categories = {} assert set() == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, categories=categories, provider=self.provider, ) def test_threat_detection_single_check(self): - bulk_checks_metatada = { + bulk_checks_metadata = { CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata() } categories = {} check_list = [CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME] assert {CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME} == load_checks_to_execute( - bulk_checks_metadata=bulk_checks_metatada, + bulk_checks_metadata=bulk_checks_metadata, check_list=check_list, categories=categories, provider=self.provider, @@ -524,3 +535,143 @@ class TestCheckLoader: provider=self.provider, ) assert exc_info.value.code == 1 + + def test_load_checks_to_execute_with_resource_groups(self): + """Test that checks are filtered by resource group""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + IAM_USER_NO_MFA_NAME: self.get_custom_check_iam_metadata(), + } + resource_groups = {"storage"} + + assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + resource_groups=resource_groups, + provider=self.provider, + ) + + def test_load_checks_to_execute_with_multiple_resource_groups(self): + """Test that checks are filtered by multiple resource groups""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + IAM_USER_NO_MFA_NAME: self.get_custom_check_iam_metadata(), + } + resource_groups = {"storage", "IAM"} + + assert { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME, + IAM_USER_NO_MFA_NAME, + } == load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + resource_groups=resource_groups, + provider=self.provider, + ) + + def test_load_checks_to_execute_with_resource_group_case_insensitive(self): + """Test that resource group matching is case-insensitive""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + IAM_USER_NO_MFA_NAME: self.get_custom_check_iam_metadata(), + } + # "iam" lowercase should match metadata "IAM", "Storage" mixed case should match "storage" + resource_groups = {"iam", "Storage"} + + assert { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME, + IAM_USER_NO_MFA_NAME, + } == load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + resource_groups=resource_groups, + provider=self.provider, + ) + + def test_load_checks_to_execute_with_invalid_resource_group(self): + """Test that invalid resource group names cause sys.exit(1)""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() + } + resource_groups = {"invalid_resource_group"} + + with pytest.raises(SystemExit) as exc_info: + load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + resource_groups=resource_groups, + provider=self.provider, + ) + assert exc_info.value.code == 1 + + def test_load_checks_to_execute_with_multiple_invalid_resource_groups(self): + """Test that multiple invalid resource group names cause sys.exit(1)""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() + } + resource_groups = {"invalid_rg_1", "invalid_rg_2"} + + with pytest.raises(SystemExit) as exc_info: + load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + resource_groups=resource_groups, + provider=self.provider, + ) + assert exc_info.value.code == 1 + + def test_load_checks_to_execute_with_mixed_valid_invalid_resource_groups(self): + """Test that mix of valid and invalid resource groups cause sys.exit(1)""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() + } + resource_groups = {"storage", "invalid_resource_group"} + + with pytest.raises(SystemExit) as exc_info: + load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + resource_groups=resource_groups, + provider=self.provider, + ) + assert exc_info.value.code == 1 + + def test_list_checks_includes_threat_detection(self): + """Test that list_checks=True includes threat-detection checks (fixes #10576)""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(), + } + + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + provider=self.provider, + list_checks=True, + ) + assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME in result + assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME in result + + def test_list_checks_with_service_includes_threat_detection(self): + """Test that list_checks=True with service filter includes threat-detection checks (fixes #10576)""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(), + } + service_list = ["cloudtrail"] + + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + service_list=service_list, + provider=self.provider, + list_checks=True, + ) + assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME in result + assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME not in result + + def test_scan_still_excludes_threat_detection_by_default(self): + """Test that without list_checks, threat-detection checks are still excluded""" + bulk_checks_metadata = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME: self.get_threat_detection_check_metadata(), + } + + result = load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metadata, + provider=self.provider, + ) + assert CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME not in result + assert S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME in result diff --git a/tests/lib/check/check_test.py b/tests/lib/check/check_test.py index 868d4c66fe..84708b96b1 100644 --- a/tests/lib/check/check_test.py +++ b/tests/lib/check/check_test.py @@ -24,7 +24,7 @@ from prowler.lib.check.check import ( remove_custom_checks_module, update_audit_metadata, ) -from prowler.lib.check.models import load_check_metadata +from prowler.lib.check.models import CheckMetadata, load_check_metadata from prowler.lib.check.utils import ( list_modules, recover_checks_from_provider, @@ -412,7 +412,7 @@ class TestCheck: }, "expected": { "CheckID": "iam_user_accesskey_unused", - "CheckTitle": "Ensure Access Keys unused are disabled", + "CheckTitle": "Access Keys unused should be disabled", "ServiceName": "iam", "Severity": "low", }, @@ -502,7 +502,7 @@ class TestCheck: "ResourceType": "AwsCustomResource", "Description": "A test custom check", "Risk": "Test risk", - "RelatedUrl": "https://example.com", + "RelatedUrl": "", "Remediation": { "Code": {"CLI": "", "NativeIaC": "", "Other": "", "Terraform": ""}, "Recommendation": {"Text": "", "Url": ""}, @@ -614,7 +614,7 @@ class TestCheck: "forensics-ready", "encryption", "internet-exposed", - "trustboundaries", + "trust-boundaries", } listed_categories = list_categories(test_bulk_checks_metadata) assert listed_categories == expected_categories @@ -958,7 +958,98 @@ class TestCheck: ) self.verify_metadata_check_id(base_directory) + def test_alibabacloud_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/alibabacloud/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_cloudflare_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/cloudflare/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_github_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/github/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_googleworkspace_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/googleworkspace/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_m365_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/m365/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_mongodbatlas_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/mongodbatlas/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_nhn_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/nhn/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_openstack_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/openstack/services", + ) + ) + self.verify_metadata_check_id(base_directory) + + def test_oraclecloud_checks_metadata_is_valid(self): + base_directory = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "../../../", + "prowler/providers/oraclecloud/services", + ) + ) + self.verify_metadata_check_id(base_directory) + def verify_metadata_check_id(self, provider_path): + errors = [] # Walk through the base directory to find all service directories for root, dirs, _ in os.walk(provider_path): # We only want to look at directories that are direct children of the base directory @@ -984,9 +1075,51 @@ class TestCheck: check_id = data.get("CheckID", None) # Compare CheckID to the check name - assert ( - check_id == check_dir - ), f"CheckID in metadata does not match the check name in {check_directory}. Found CheckID: {check_id}" + if check_id != check_dir: + errors.append( + f"CheckID in metadata does not match the check name in {check_directory}. Found CheckID: {check_id}" + ) + + # Validate metadata against Pydantic validators + try: + CheckMetadata.parse_file(metadata_file_path) + except Exception as e: + errors.append( + f"Metadata validation failed for {metadata_file_path}: {e}" + ) + + assert not errors, "\n\n".join(errors) + + def test_execute_oraclecloud_mutelist_passes_tenancy_id(self): + """Test that execute() passes tenancy_id to is_finding_muted for OCI provider.""" + tenancy_id = "ocid1.tenancy.oc1..aaaaaaaexample" + + finding = Mock() + finding.status = "PASS" + finding.muted = False + + check = Mock() + check.CheckID = "oci_test_check" + check.execute = Mock(return_value=[finding]) + + provider = mock.MagicMock() + provider.type = "oraclecloud" + provider.identity.tenancy_id = tenancy_id + provider.mutelist.mutelist = {"Accounts": {tenancy_id: {}}} + provider.mutelist.is_finding_muted = Mock(return_value=True) + + findings = execute( + check=check, + global_provider=provider, + custom_checks_metadata=None, + output_options=None, + ) + + provider.mutelist.is_finding_muted.assert_called_once_with( + tenancy_id=tenancy_id, + finding=finding, + ) + assert findings[0].muted is True def test_execute_check_exception_only_logs(self, caplog): caplog.set_level(ERROR) diff --git a/tests/lib/check/compliance_check_test.py b/tests/lib/check/compliance_check_test.py index 3fae02fa87..73f93e3b38 100644 --- a/tests/lib/check/compliance_check_test.py +++ b/tests/lib/check/compliance_check_test.py @@ -197,7 +197,7 @@ class TestCompliance: Provider="aws", CheckID="accessanalyzer_enabled", CheckTitle="Check 1", - CheckType=["type1"], + CheckType=["TTPs/Initial Access"], ServiceName="accessanalyzer", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -205,7 +205,7 @@ class TestCompliance: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -213,9 +213,12 @@ class TestCompliance: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/accessanalyzer_enabled", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -225,7 +228,7 @@ class TestCompliance: Provider="aws", CheckID="iam_user_mfa_enabled_console_access", CheckTitle="Check 2", - CheckType=["type2"], + CheckType=["TTPs/Credential Access"], ServiceName="iam", SubServiceName="subservice2", ResourceIdTemplate="template2", @@ -233,7 +236,7 @@ class TestCompliance: ResourceType="resource2", Description="Description 2", Risk="risk2", - RelatedUrl="url2", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli2", @@ -241,9 +244,12 @@ class TestCompliance: "Other": "other2", "Terraform": "terraform2", }, - "Recommendation": {"Text": "text2", "Url": "url2"}, + "Recommendation": { + "Text": "text2", + "Url": "https://hub.prowler.com/check/iam_user_mfa_enabled_console_access", + }, }, - Categories=["categorytwo"], + Categories=["logging"], DependsOn=["dependency2"], RelatedTo=["related2"], Notes="notes2", diff --git a/tests/lib/check/custom_checks_metadata_test.py b/tests/lib/check/custom_checks_metadata_test.py index 69f27880a8..9037131675 100644 --- a/tests/lib/check/custom_checks_metadata_test.py +++ b/tests/lib/check/custom_checks_metadata_test.py @@ -27,7 +27,9 @@ S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_REMEDIATION_OTHER = "https://github.com/clou S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_REMEDIATION_TEXT = ( "Enable the S3 bucket level public access block." ) -S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_REMEDIATION_URL = "https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html" +S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_REMEDIATION_URL = ( + "https://hub.prowler.com/check/s3_bucket_level_public_access_block" +) class TestCustomChecksMetadata: @@ -36,7 +38,7 @@ class TestCustomChecksMetadata: Provider="aws", CheckID=S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME, CheckTitle="Check S3 Bucket Level Public Access Block.", - CheckType=["Data Protection"], + CheckType=["Sensitive Data Identifications/PII"], CheckAliases=[], ServiceName="s3", SubServiceName="", @@ -45,7 +47,7 @@ class TestCustomChecksMetadata: ResourceType="AwsS3Bucket", Description="Check S3 Bucket Level Public Access Block.", Risk="Public access policies may be applied to sensitive data buckets.", - RelatedUrl="https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html", + RelatedUrl="", Remediation=Remediation( Code=Code( NativeIaC="", diff --git a/tests/lib/check/fixtures/bulk_checks_metadata.py b/tests/lib/check/fixtures/bulk_checks_metadata.py index d0eee8bc66..5cd8c649e4 100644 --- a/tests/lib/check/fixtures/bulk_checks_metadata.py +++ b/tests/lib/check/fixtures/bulk_checks_metadata.py @@ -4,14 +4,16 @@ test_bulk_checks_metadata = { "vpc_peering_routing_tables_with_least_privilege": CheckMetadata( Provider="aws", CheckID="vpc_peering_routing_tables_with_least_privilege", - CheckTitle="Ensure routing tables for VPC peering are least access.", - CheckType=["Infrastructure Security"], + CheckTitle="VPC peering routing tables should follow least access.", + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], ServiceName="vpc", SubServiceName="route_table", ResourceIdTemplate="arn:partition:service:region:account-id:resource-id", Severity="medium", ResourceType="AwsEc2VpcPeeringConnection", - Description="Ensure routing tables for VPC peering are least access.", + Description="VPC peering routing tables should follow least access.", Risk="Being highly selective in peering routing tables is a very effective way of minimizing the impact of breach as resources outside of these routes are inaccessible to the peered VPC.", RelatedUrl="", Remediation=Remediation( @@ -23,7 +25,7 @@ test_bulk_checks_metadata = { ), Recommendation=Recommendation( Text="Review routing tables of peered VPCs for whether they route all subnets of each VPC and whether that is necessary to accomplish the intended purposes for peering the VPCs.", - Url="https://docs.aws.amazon.com/vpc/latest/peering/peering-configurations-partial-access.html", + Url="https://hub.prowler.com/check/vpc_peering_routing_tables_with_least_privilege", ), ), Categories=["forensics-ready"], @@ -35,8 +37,10 @@ test_bulk_checks_metadata = { "vpc_subnet_different_az": CheckMetadata( Provider="aws", CheckID="vpc_subnet_different_az", - CheckTitle="Ensure all vpc has subnets in more than one availability zone", - CheckType=["Infrastructure Security"], + CheckTitle="VPC should have subnets in more than one availability zone", + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], ServiceName="vpc", SubServiceName="subnet", ResourceIdTemplate="arn:partition:service:region:account-id:resource-id", @@ -44,13 +48,13 @@ test_bulk_checks_metadata = { ResourceType="AwsEc2Vpc", Description="Ensure all vpc has subnets in more than one availability zone", Risk="", - RelatedUrl="https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Scenario2.html", + RelatedUrl="", Remediation=Remediation( Code=Code( NativeIaC="", Terraform="", CLI="aws ec2 create-subnet", Other="" ), Recommendation=Recommendation( - Text="Ensure all vpc has subnets in more than one availability zone", + Text="VPC should have subnets in more than one availability zone", Url="", ), ), @@ -63,8 +67,10 @@ test_bulk_checks_metadata = { "vpc_subnet_separate_private_public": CheckMetadata( Provider="aws", CheckID="vpc_subnet_separate_private_public", - CheckTitle="Ensure all vpc has public and private subnets defined", - CheckType=["Infrastructure Security"], + CheckTitle="VPC should have public and private subnets defined", + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], ServiceName="vpc", SubServiceName="subnet", ResourceIdTemplate="arn:partition:service:region:account-id:resource-id", @@ -72,16 +78,16 @@ test_bulk_checks_metadata = { ResourceType="AwsEc2Vpc", Description="Ensure all vpc has public and private subnets defined", Risk="", - RelatedUrl="https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Scenario2.html", + RelatedUrl="", Remediation=Remediation( Code=Code( NativeIaC="", Terraform="", CLI="aws ec2 create-subnet", Other="" ), Recommendation=Recommendation( - Text="Ensure all vpc has public and private subnets defined", Url="" + Text="VPC should have public and private subnets defined", Url="" ), ), - Categories=["internet-exposed", "trustboundaries"], + Categories=["internet-exposed", "trust-boundaries"], DependsOn=[], RelatedTo=[], Notes="", @@ -90,16 +96,18 @@ test_bulk_checks_metadata = { "workspaces_volume_encryption_enabled": CheckMetadata( Provider="aws", CheckID="workspaces_volume_encryption_enabled", - CheckTitle="Ensure that your Amazon WorkSpaces storage volumes are encrypted in order to meet security and compliance requirements", - CheckType=[], + CheckTitle="Amazon WorkSpaces storage volumes should be encrypted", + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis" + ], ServiceName="workspaces", SubServiceName="", ResourceIdTemplate="arn:aws:workspaces:region:account-id:workspace", Severity="high", ResourceType="AwsWorkspaces", - Description="Ensure that your Amazon WorkSpaces storage volumes are encrypted in order to meet security and compliance requirements", + Description="Amazon WorkSpaces storage volumes should be encrypted to meet security and compliance requirements", Risk="If the value listed in the Volume Encryption column is Disabled the selected AWS WorkSpaces instance volumes (root and user volumes) are not encrypted. Therefore your data-at-rest is not protected from unauthorized access and does not meet the compliance requirements regarding data encryption.", - RelatedUrl="https://docs.aws.amazon.com/workspaces/latest/adminguide/encrypt-workspaces.html", + RelatedUrl="", Remediation=Remediation( Code=Code( NativeIaC="https://docs.prowler.com/checks/ensure-that-workspace-root-volumes-are-encrypted#cloudformation", @@ -109,7 +117,7 @@ test_bulk_checks_metadata = { ), Recommendation=Recommendation( Text="WorkSpaces is integrated with the AWS Key Management Service (AWS KMS). This enables you to encrypt storage volumes of WorkSpaces using AWS KMS Key. When you launch a WorkSpace you can encrypt the root volume (for Microsoft Windows - the C drive; for Linux - /) and the user volume (for Windows - the D drive; for Linux - /home). Doing so ensures that the data stored at rest - disk I/O to the volume - and snapshots created from the volumes are all encrypted", - Url="https://docs.aws.amazon.com/workspaces/latest/adminguide/encrypt-workspaces.html", + Url="https://hub.prowler.com/check/workspaces_volume_encryption_enabled", ), ), Categories=["encryption"], @@ -121,21 +129,23 @@ test_bulk_checks_metadata = { "workspaces_vpc_2private_1public_subnets_nat": CheckMetadata( Provider="aws", CheckID="workspaces_vpc_2private_1public_subnets_nat", - CheckTitle="Ensure that the Workspaces VPC are deployed following the best practices using 1 public subnet and 2 private subnets with a NAT Gateway attached", - CheckType=[], + CheckTitle="Workspaces VPC should use 1 public and 2 private subnets with NAT Gateway", + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis" + ], ServiceName="workspaces", SubServiceName="", ResourceIdTemplate="arn:aws:workspaces:region:account-id:workspace", Severity="medium", ResourceType="AwsWorkspaces", - Description="Ensure that the Workspaces VPC are deployed following the best practices using 1 public subnet and 2 private subnets with a NAT Gateway attached", + Description="Workspaces VPC should be deployed with 1 public subnet and 2 private subnets with a NAT Gateway attached", Risk="Proper network segmentation is a key security best practice. Workspaces VPC should be deployed using 1 public subnet and 2 private subnets with a NAT Gateway attached", - RelatedUrl="https://docs.aws.amazon.com/workspaces/latest/adminguide/amazon-workspaces-vpc.html", + RelatedUrl="", Remediation=Remediation( Code=Code(NativeIaC="", Terraform="", CLI="", Other=""), Recommendation=Recommendation( Text="Follow the documentation and deploy Workspaces VPC using 1 public subnet and 2 private subnets with a NAT Gateway attached", - Url="https://docs.aws.amazon.com/workspaces/latest/adminguide/amazon-workspaces-vpc.html", + Url="https://hub.prowler.com/check/workspaces_vpc_2private_1public_subnets_nat", ), ), Categories=[], diff --git a/tests/lib/check/fixtures/metadata.json b/tests/lib/check/fixtures/metadata.json index a376d491a9..b26438aac8 100644 --- a/tests/lib/check/fixtures/metadata.json +++ b/tests/lib/check/fixtures/metadata.json @@ -1,10 +1,10 @@ { "Categories": [ - "cat-one", - "cat-two" + "encryption", + "logging" ], "CheckID": "iam_user_accesskey_unused", - "CheckTitle": "Ensure Access Keys unused are disabled", + "CheckTitle": "Access Keys unused should be disabled", "CheckType": [ "Software and Configuration Checks" ], @@ -25,14 +25,14 @@ "othercheck1", "othercheck2" ], - "Description": "Ensure Access Keys unused are disabled", + "Description": "Access Keys unused should be disabled", "Notes": "additional information", "Provider": "aws", "RelatedTo": [ "othercheck3", "othercheck4" ], - "RelatedUrl": "https://serviceofficialsiteorpageforthissubject", + "RelatedUrl": "", "Remediation": { "Code": { "CLI": "cli command or URL to the cli command location.", @@ -42,7 +42,7 @@ }, "Recommendation": { "Text": "Run sudo yum update and cross your fingers and toes.", - "Url": "https://myfp.com/recommendations/dangerous_things_and_how_to_fix_them.html" + "Url": "https://hub.prowler.com/check/iam_user_accesskey_unused" } }, "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 3c490e20c5..815479cdfa 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -11,7 +11,7 @@ mock_metadata = CheckMetadata( Provider="aws", CheckID="accessanalyzer_enabled", CheckTitle="Check 1", - CheckType=["type1"], + CheckType=["Software and Configuration Checks/AWS Security Best Practices"], ServiceName="accessanalyzer", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -19,7 +19,7 @@ mock_metadata = CheckMetadata( ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -27,9 +27,12 @@ mock_metadata = CheckMetadata( "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/accessanalyzer_enabled", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -40,7 +43,7 @@ mock_metadata_lambda = CheckMetadata( Provider="aws", CheckID="awslambda_function_url_public", CheckTitle="Check 1", - CheckType=["type1"], + CheckType=["Software and Configuration Checks/AWS Security Best Practices"], ServiceName="awslambda", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -48,7 +51,7 @@ mock_metadata_lambda = CheckMetadata( ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -56,9 +59,12 @@ mock_metadata_lambda = CheckMetadata( "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/awslambda_function_url_public", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -175,7 +181,7 @@ class TestCheckMetada: bulk_metadata = CheckMetadata.get_bulk(provider="aws") result = CheckMetadata.list( - bulk_checks_metadata=bulk_metadata, category="categoryone" + bulk_checks_metadata=bulk_metadata, category="encryption" ) # Assertions @@ -330,13 +336,1473 @@ class TestCheckMetada: result = CheckMetadata.list(bulk_checks_metadata=bulk_metadata) assert result == set() + +class TestCheckMetadataValidators: + """Test class for CheckMetadata validators""" + + def test_valid_category_success(self): + """Test valid category validation with valid categories""" + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption", "logging", "secrets"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + # Should not raise any validation error + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.Categories == ["encryption", "logging", "secrets"] + + def test_valid_category_failure_non_string(self): + """Test valid category validation fails with non-string category""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": [123], # Invalid: number instead of string + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "Categories must be a list of strings" in str(exc_info.value) + + def test_valid_category_failure_invalid_format(self): + """Test valid category validation fails with invalid format""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["invalid_category!"], # Invalid: contains special character + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert ( + "Categories can only contain lowercase letters, numbers and hyphen" + in str(exc_info.value) + ) + + def test_valid_category_failure_not_predefined(self): + """Test valid category validation fails with non-predefined category""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["not-a-real-category"], # Invalid: not in predefined list + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "Invalid category: 'not-a-real-category'. Must be one of:" in str( + exc_info.value + ) + + def test_valid_category_all_predefined_values(self): + """Test that all predefined categories are accepted""" + from prowler.lib.check.models import VALID_CATEGORIES + + for category in VALID_CATEGORIES: + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": [category], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + check_metadata = CheckMetadata(**valid_metadata) + assert category in check_metadata.Categories + + def test_severity_to_lower_success(self): + """Test severity validation converts to lowercase""" + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "HIGH", # Uppercase - should be converted to lowercase + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.Severity == "high" + + def test_valid_cli_command_success(self): + """Test CLI command validation with valid command""" + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "aws iam create-role --role-name test", # Valid CLI command + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + # Should not raise any validation error + check_metadata = CheckMetadata(**valid_metadata) + assert "aws iam create-role" in check_metadata.Remediation.Code.CLI + + def test_valid_cli_command_failure_url(self): + """Test CLI command validation fails with URL""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "https://example.com/command", # Invalid: URL instead of command + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "CLI command cannot be an URL" in str(exc_info.value) + + def test_valid_resource_type_success(self): + """Test resource type validation with valid resource type""" + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "AWS::IAM::Role", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.ResourceType == "AWS::IAM::Role" + + def test_valid_resource_type_failure_empty(self): + """Test resource type validation fails with empty string""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "", # Invalid: empty string + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "ResourceType must be a non-empty string" in str(exc_info.value) + + def test_validate_service_name_success(self): + """Test service name validation with valid service name matching CheckID""" + valid_metadata = { + "Provider": "azure", + "CheckID": "s3_bucket_public_read", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "s3", # Matches first part of CheckID + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "AWS::S3::Bucket", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.ServiceName == "s3" + + def test_validate_service_name_failure_mismatch(self): + """Test service name validation fails when not matching CheckID""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "s3_bucket_public_read", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "ec2", # Does not match first part of CheckID + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "AWS::S3::Bucket", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert ( + "ServiceName ec2 does not belong to CheckID s3_bucket_public_read" + in str(exc_info.value) + ) + + def test_validate_service_name_failure_uppercase(self): + """Test service name validation fails with uppercase""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "S3_bucket_public_read", + "CheckTitle": "Test Check", + "CheckType": ["TTPs/Discovery"], + "ServiceName": "S3", # Invalid: uppercase + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "AWS::S3::Bucket", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "ServiceName S3 must be in lowercase" in str(exc_info.value) + + def test_validate_service_name_iac_provider_success(self): + """Test service name validation allows any service name for IAC provider""" + valid_metadata = { + "Provider": "iac", + "CheckID": "custom_check_id", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "CustomService", # Valid for IAC provider + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.ServiceName == "CustomService" + + def test_valid_check_id_success(self): + """Test CheckID validation with valid check ID""" + valid_metadata = { + "Provider": "azure", + "CheckID": "s3_bucket_public_read_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "s3", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "AWS::S3::Bucket", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.CheckID == "s3_bucket_public_read_check" + + def test_valid_check_id_failure_empty(self): + """Test CheckID validation fails with empty string""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "", # Invalid: empty string + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "CheckID must be a non-empty string" in str(exc_info.value) + + def test_valid_check_id_failure_hyphen(self): + """Test CheckID validation fails with hyphen""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "s3-bucket-public-read", # Invalid: contains hyphens + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "s3", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "AWS::S3::Bucket", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert ( + "CheckID s3-bucket-public-read contains a hyphen, which is not allowed" + in str(exc_info.value) + ) + + def test_validate_check_title_success(self): + """Test CheckTitle validation with valid title""" + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "A" * 150, # Exactly 150 characters + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert len(check_metadata.CheckTitle) == 150 + + def test_validate_check_title_failure_too_long(self): + """Test CheckTitle validation fails when too long""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "A" * 151, # Too long: 151 characters + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "CheckTitle must not exceed 150 characters, got 151 characters" in str( + exc_info.value + ) + + def test_validate_check_title_failure_starts_with_ensure(self): + """Test CheckTitle validation fails when starting with 'Ensure'""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Ensure S3 buckets have encryption enabled", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "CheckTitle must not start with 'Ensure'" in str(exc_info.value) + + def test_validate_related_url_must_be_empty(self): + """Test RelatedUrl validation fails when not empty""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "https://example.com", # Invalid: must be empty + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "RelatedUrl must be empty" in str(exc_info.value) + + def test_validate_related_url_empty_is_valid(self): + """Test RelatedUrl validation passes when empty""" + valid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.RelatedUrl == "" + + def test_validate_recommendation_url_must_be_hub(self): + """Test Recommendation URL validation fails when not pointing to Prowler Hub""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://docs.aws.amazon.com/some-page", # Invalid: not HUB + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "Remediation Recommendation URL must point to Prowler Hub" in str( + exc_info.value + ) + + def test_validate_recommendation_url_hub_is_valid(self): + """Test Recommendation URL validation passes with Prowler Hub URL""" + valid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert ( + check_metadata.Remediation.Recommendation.Url + == "https://hub.prowler.com/check/test_check" + ) + + def test_validate_recommendation_url_empty_is_valid(self): + """Test Recommendation URL validation passes when empty""" + valid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.Remediation.Recommendation.Url == "" + + def test_validate_check_type_non_aws_must_be_empty(self): + """Test CheckType must be empty for non-AWS providers""" + invalid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": ["SomeType"], # Invalid: non-AWS must be empty + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "CheckType must be empty for non-AWS providers" in str(exc_info.value) + + def test_validate_check_type_success(self): + """Test CheckType validation with valid check types""" + valid_metadata = { + "Provider": "azure", # Using non-AWS provider to avoid config validation + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.CheckType == [] + + def test_validate_check_type_failure_empty_string(self): + """Test CheckType validation fails with empty string in list""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": ["TTPs/Discovery", ""], # Invalid: empty string in list + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "CheckType list cannot contain empty strings" in str(exc_info.value) + + def test_validate_description_success(self): + """Test Description validation with valid description""" + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "A" * 400, # Exactly 400 characters + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert len(check_metadata.Description) == 400 + + def test_validate_description_failure_too_long(self): + """Test Description validation fails when too long""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "A" * 401, # Too long: 401 characters + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "Description must not exceed 400 characters, got 401 characters" in str( + exc_info.value + ) + + def test_validate_risk_success(self): + """Test Risk validation with valid risk""" + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "A" * 400, # Exactly 400 characters + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert len(check_metadata.Risk) == 400 + + def test_validate_risk_failure_too_long(self): + """Test Risk validation fails when too long""" + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "A" * 401, # Too long: 401 characters + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "Risk must not exceed 400 characters, got 401 characters" in str( + exc_info.value + ) + + def test_validate_check_type_aws_invalid_type(self): + """Test CheckType validation fails with invalid AWS CheckType""" + + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": ["InvalidType"], # Invalid: not in AWS config + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "Invalid CheckType: 'InvalidType'" in str(exc_info.value) + + def test_validate_check_type_aws_valid_hierarchy_path(self): + """Test CheckType validation succeeds with valid AWS CheckType hierarchy path""" + + valid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": ["TTPs/Initial Access"], # Valid: partial path in hierarchy + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.CheckType == ["TTPs/Initial Access"] + + def test_validate_check_type_non_aws_provider(self): + """Test CheckType validation requires empty list for non-AWS providers""" + valid_metadata = { + "Provider": "azure", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [], # Non-AWS providers must have empty CheckType + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.CheckType == [] + + def test_validate_check_type_aws_validation_called(self): + """Test that AWS CheckType validation function works for AWS provider""" + + valid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": ["Effects/Data Exposure"], # Valid AWS CheckType + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.CheckType == ["Effects/Data Exposure"] + + def test_validate_check_type_multiple_types_all_valid(self): + """Test CheckType validation with multiple valid types for AWS provider""" + valid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "TTPs/Discovery", + "Effects/Data Exposure", + ], # Multiple valid AWS types + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + check_metadata = CheckMetadata(**valid_metadata) + assert check_metadata.CheckType == ["TTPs/Discovery", "Effects/Data Exposure"] + + def test_validate_check_type_aws_multiple_types_mixed_validity(self): + """Test CheckType validation with multiple types where one is invalid for AWS""" + + invalid_metadata = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": ["TTPs/Discovery", "InvalidType"], # One valid, one invalid + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**invalid_metadata) + assert "Invalid CheckType: 'InvalidType'" in str(exc_info.value) + def test_additional_urls_valid_empty_list(self): """Test AdditionalURLs with valid empty list (default)""" metadata = CheckMetadata( Provider="aws", CheckID="test_check", CheckTitle="Test Check", - CheckType=["type1"], + CheckType=["Software and Configuration Checks/AWS Security Best Practices"], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -344,7 +1810,7 @@ class TestCheckMetada: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -352,9 +1818,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -374,7 +1843,7 @@ class TestCheckMetada: Provider="aws", CheckID="test_check", CheckTitle="Test Check", - CheckType=["type1"], + CheckType=["Software and Configuration Checks/AWS Security Best Practices"], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -382,7 +1851,7 @@ class TestCheckMetada: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -390,9 +1859,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -408,7 +1880,9 @@ class TestCheckMetada: Provider="aws", CheckID="test_check", CheckTitle="Test Check", - CheckType=["type1"], + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices" + ], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -416,7 +1890,7 @@ class TestCheckMetada: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -424,9 +1898,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -442,7 +1919,9 @@ class TestCheckMetada: Provider="aws", CheckID="test_check", CheckTitle="Test Check", - CheckType=["type1"], + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices" + ], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -450,7 +1929,7 @@ class TestCheckMetada: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -458,9 +1937,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -476,7 +1958,9 @@ class TestCheckMetada: Provider="aws", CheckID="test_check", CheckTitle="Test Check", - CheckType=["type1"], + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices" + ], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -484,7 +1968,7 @@ class TestCheckMetada: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -492,9 +1976,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -510,7 +1997,9 @@ class TestCheckMetada: Provider="aws", CheckID="test_check", CheckTitle="Test Check", - CheckType=["type1"], + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices" + ], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -518,7 +2007,7 @@ class TestCheckMetada: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="url1", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -526,9 +2015,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -547,7 +2039,7 @@ class TestCheckMetada: Provider="aws", CheckID="test_check_empty_fields", CheckTitle="Test Check with Empty Fields", - CheckType=["type1"], + CheckType=["Software and Configuration Checks/AWS Security Best Practices"], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -563,9 +2055,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -583,7 +2078,7 @@ class TestCheckMetada: Provider="aws", CheckID="test_check_defaults", CheckTitle="Test Check with Default Fields", - CheckType=["type1"], + CheckType=["Software and Configuration Checks/AWS Security Best Practices"], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -599,9 +2094,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -620,7 +2118,9 @@ class TestCheckMetada: Provider="aws", CheckID="test_check_none_related_url", CheckTitle="Test Check with None RelatedUrl", - CheckType=["type1"], + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices" + ], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -636,9 +2136,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -655,7 +2158,9 @@ class TestCheckMetada: Provider="aws", CheckID="test_check_none_additional_urls", CheckTitle="Test Check with None AdditionalURLs", - CheckType=["type1"], + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices" + ], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -663,7 +2168,7 @@ class TestCheckMetada: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="https://example.com", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -671,9 +2176,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -690,7 +2198,9 @@ class TestCheckMetada: Provider="aws", CheckID="test_check_invalid_additional_urls", CheckTitle="Test Check with Invalid AdditionalURLs", - CheckType=["type1"], + CheckType=[ + "Software and Configuration Checks/AWS Security Best Practices" + ], ServiceName="test", SubServiceName="subservice1", ResourceIdTemplate="template1", @@ -698,7 +2208,7 @@ class TestCheckMetada: ResourceType="resource1", Description="Description 1", Risk="risk1", - RelatedUrl="https://example.com", + RelatedUrl="", Remediation={ "Code": { "CLI": "cli1", @@ -706,9 +2216,12 @@ class TestCheckMetada: "Other": "other1", "Terraform": "terraform1", }, - "Recommendation": {"Text": "text1", "Url": "url1"}, + "Recommendation": { + "Text": "text1", + "Url": "https://hub.prowler.com/check/test_check", + }, }, - Categories=["categoryone"], + Categories=["encryption"], DependsOn=["dependency1"], RelatedTo=["related1"], Notes="notes1", @@ -719,6 +2232,101 @@ class TestCheckMetada: assert "AdditionalURLs must be a list" in str(exc_info.value) +class TestResourceGroupValidator: + """Test class for ResourceGroup validator""" + + def _base_metadata(self, **overrides): + """Helper to build valid metadata with overrides""" + base = { + "Provider": "aws", + "CheckID": "test_check", + "CheckTitle": "Test Check", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "test", + "SubServiceName": "subtest", + "ResourceIdTemplate": "template", + "Severity": "high", + "ResourceType": "TestResource", + "Description": "Test description", + "Risk": "Test risk", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "test command", + "NativeIaC": "test native", + "Other": "test other", + "Terraform": "test terraform", + }, + "Recommendation": { + "Text": "test recommendation", + "Url": "https://hub.prowler.com/check/test_check", + }, + }, + "Categories": ["encryption"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Test notes", + } + base.update(overrides) + return base + + @pytest.mark.parametrize( + "resource_group", + [ + "compute", + "container", + "serverless", + "database", + "storage", + "network", + "IAM", + "messaging", + "security", + "monitoring", + "api_gateway", + "ai_ml", + "governance", + "collaboration", + "devops", + "analytics", + ], + ) + def test_valid_resource_group(self, resource_group): + """Test all valid ResourceGroup values are accepted""" + metadata = CheckMetadata(**self._base_metadata(ResourceGroup=resource_group)) + assert metadata.ResourceGroup == resource_group + + def test_resource_group_empty_string_allowed(self): + """Test that empty string (default) is allowed for ResourceGroup""" + metadata = CheckMetadata(**self._base_metadata(ResourceGroup="")) + assert metadata.ResourceGroup == "" + + def test_resource_group_default_is_empty(self): + """Test that ResourceGroup defaults to empty string when not provided""" + metadata = CheckMetadata(**self._base_metadata()) + assert metadata.ResourceGroup == "" + + def test_resource_group_invalid_value(self): + """Test that invalid ResourceGroup value raises ValidationError""" + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**self._base_metadata(ResourceGroup="invalid_group")) + assert "Invalid ResourceGroup: 'invalid_group'" in str(exc_info.value) + + def test_resource_group_case_sensitive(self): + """Test that ResourceGroup validation is case-sensitive (IAM, not iam)""" + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**self._base_metadata(ResourceGroup="iam")) + assert "Invalid ResourceGroup: 'iam'" in str(exc_info.value) + + def test_resource_group_typo(self): + """Test that typos in ResourceGroup are rejected""" + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**self._base_metadata(ResourceGroup="computee")) + assert "Invalid ResourceGroup: 'computee'" in str(exc_info.value) + + class TestCheck: @mock.patch("prowler.lib.check.models.CheckMetadata.parse_file") def test_verify_names_consistency_all_match(self, mock_parse_file): @@ -810,3 +2418,249 @@ class TestCheck: msg = str(excinfo.value) assert "!= class name" in msg assert "!= file name" in msg + + +class TestExternalToolProviderValidatorBypass: + """Validators skip strict rules for external tool providers (image, iac, llm).""" + + EXTERNAL_METADATA_BASE = { + "Provider": "image", + "CheckID": "CVE-2024-1234", + "CheckTitle": "OpenSSL Buffer Overflow", + "CheckType": ["Container Image Security"], + "ServiceName": "container-image", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "container-image", + "ResourceGroup": "container", + "Description": "A buffer overflow vulnerability.", + "Risk": "Remote code execution.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "", + }, + "Recommendation": { + "Text": "Upgrade openssl", + "Url": "https://avd.aquasec.com/nvd/cve-2024-1234", + }, + }, + "Categories": ["vulnerability"], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + } + + def test_external_provider_allows_non_hub_recommendation_url(self): + metadata = CheckMetadata(**self.EXTERNAL_METADATA_BASE) + assert ( + metadata.Remediation.Recommendation.Url + == "https://avd.aquasec.com/nvd/cve-2024-1234" + ) + + def test_native_provider_rejects_non_hub_recommendation_url(self): + data = { + **self.EXTERNAL_METADATA_BASE, + "Provider": "azure", + "CheckID": "test_check", + "ServiceName": "test", + "CheckType": [], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "", + }, + "Recommendation": { + "Text": "Fix it", + "Url": "https://avd.aquasec.com/nvd/cve-2024-1234", + }, + }, + } + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**data) + assert "Prowler Hub" in str(exc_info.value) + + def test_external_provider_allows_long_description(self): + data = {**self.EXTERNAL_METADATA_BASE, "Description": "A" * 500} + metadata = CheckMetadata(**data) + assert len(metadata.Description) == 500 + + def test_native_provider_rejects_long_description(self): + data = { + **self.EXTERNAL_METADATA_BASE, + "Provider": "azure", + "CheckID": "test_check", + "ServiceName": "test", + "CheckType": [], + "Categories": ["encryption"], + "Description": "A" * 401, + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "", + }, + "Recommendation": { + "Text": "", + "Url": "", + }, + }, + } + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**data) + assert "Description must not exceed 400 characters" in str(exc_info.value) + + def test_external_provider_allows_long_risk(self): + data = {**self.EXTERNAL_METADATA_BASE, "Risk": "R" * 500} + metadata = CheckMetadata(**data) + assert len(metadata.Risk) == 500 + + def test_native_provider_rejects_long_risk(self): + data = { + **self.EXTERNAL_METADATA_BASE, + "Provider": "azure", + "CheckID": "test_check", + "ServiceName": "test", + "CheckType": [], + "Categories": ["encryption"], + "Risk": "R" * 401, + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "", + }, + "Recommendation": { + "Text": "", + "Url": "", + }, + }, + } + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**data) + assert "Risk must not exceed 400 characters" in str(exc_info.value) + + def test_external_provider_allows_long_check_title(self): + data = {**self.EXTERNAL_METADATA_BASE, "CheckTitle": "T" * 200} + metadata = CheckMetadata(**data) + assert len(metadata.CheckTitle) == 200 + + def test_native_provider_rejects_long_check_title(self): + data = { + **self.EXTERNAL_METADATA_BASE, + "Provider": "azure", + "CheckID": "test_check", + "ServiceName": "test", + "CheckType": [], + "Categories": ["encryption"], + "CheckTitle": "T" * 151, + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "", + }, + "Recommendation": { + "Text": "", + "Url": "", + }, + }, + } + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**data) + assert "CheckTitle must not exceed 150 characters" in str(exc_info.value) + + def test_external_provider_allows_non_standard_category(self): + data = {**self.EXTERNAL_METADATA_BASE, "Categories": ["vulnerability"]} + metadata = CheckMetadata(**data) + assert metadata.Categories == ["vulnerability"] + + def test_native_provider_rejects_non_standard_category(self): + data = { + **self.EXTERNAL_METADATA_BASE, + "Provider": "azure", + "CheckID": "test_check", + "ServiceName": "test", + "CheckType": [], + "Categories": ["vulnerability"], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "", + }, + "Recommendation": { + "Text": "", + "Url": "", + }, + }, + } + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**data) + assert "Invalid category" in str(exc_info.value) + + def test_external_provider_allows_ensure_prefix_in_title(self): + data = { + **self.EXTERNAL_METADATA_BASE, + "CheckTitle": "Ensure containers run as non-root", + } + metadata = CheckMetadata(**data) + assert metadata.CheckTitle == "Ensure containers run as non-root" + + def test_external_provider_allows_non_empty_related_url(self): + data = { + **self.EXTERNAL_METADATA_BASE, + "RelatedUrl": "https://avd.aquasec.com/nvd/cve-2024-1234", + } + metadata = CheckMetadata(**data) + assert metadata.RelatedUrl == "https://avd.aquasec.com/nvd/cve-2024-1234" + + def test_native_provider_rejects_non_empty_related_url(self): + data = { + **self.EXTERNAL_METADATA_BASE, + "Provider": "azure", + "CheckID": "test_check", + "ServiceName": "test", + "CheckType": [], + "Categories": ["encryption"], + "RelatedUrl": "https://example.com", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "", + }, + "Recommendation": { + "Text": "", + "Url": "", + }, + }, + } + with pytest.raises(ValidationError) as exc_info: + CheckMetadata(**data) + assert "RelatedUrl must be empty" in str(exc_info.value) + + def test_all_external_providers_bypass(self): + for provider in ("image", "iac", "llm"): + data = { + **self.EXTERNAL_METADATA_BASE, + "Provider": provider, + "Description": "D" * 500, + "Risk": "R" * 500, + "CheckTitle": "T" * 200, + "Categories": ["vulnerability"], + "RelatedUrl": "https://example.com/vuln", + } + metadata = CheckMetadata(**data) + assert metadata.Provider == provider diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index 4ca715ee06..e22ffac69b 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,7 +17,7 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel,dashboard,iac,image} ..." def mock_get_available_providers(): @@ -81,6 +81,7 @@ class Test_Parser: assert not parsed.severity assert not parsed.compliance assert len(parsed.category) == 0 + assert len(parsed.resource_group) == 0 assert not parsed.excluded_check assert not parsed.excluded_service assert not parsed.excluded_checks_file @@ -89,6 +90,7 @@ class Test_Parser: assert not parsed.list_compliance assert not parsed.list_compliance_requirements assert not parsed.list_categories + assert not parsed.list_resource_groups assert not parsed.profile assert not parsed.role assert parsed.session_duration == 3600 @@ -131,6 +133,7 @@ class Test_Parser: assert not parsed.severity assert not parsed.compliance assert len(parsed.category) == 0 + assert len(parsed.resource_group) == 0 assert not parsed.excluded_check assert not parsed.excluded_service assert not parsed.excluded_checks_file @@ -139,6 +142,7 @@ class Test_Parser: assert not parsed.list_compliance assert not parsed.list_compliance_requirements assert not parsed.list_categories + assert not parsed.list_resource_groups assert len(parsed.subscription_id) == 0 assert not parsed.az_cli_auth assert parsed.sp_env_auth @@ -173,6 +177,7 @@ class Test_Parser: assert not parsed.severity assert not parsed.compliance assert len(parsed.category) == 0 + assert len(parsed.resource_group) == 0 assert not parsed.excluded_check assert not parsed.excluded_service assert not parsed.excluded_checks_file @@ -181,6 +186,7 @@ class Test_Parser: assert not parsed.list_compliance assert not parsed.list_compliance_requirements assert not parsed.list_categories + assert not parsed.list_resource_groups assert not parsed.credentials_file def test_default_parser_no_arguments_kubernetes(self): @@ -210,6 +216,7 @@ class Test_Parser: assert not parsed.severity assert not parsed.compliance assert len(parsed.category) == 0 + assert len(parsed.resource_group) == 0 assert not parsed.excluded_check assert not parsed.excluded_service assert not parsed.excluded_checks_file @@ -218,6 +225,7 @@ class Test_Parser: assert not parsed.list_compliance assert not parsed.list_compliance_requirements assert not parsed.list_categories + assert not parsed.list_resource_groups assert parsed.kubeconfig_file == "~/.kube/config" assert not parsed.context assert not parsed.namespace @@ -723,6 +731,32 @@ class Test_Parser: assert category_1 in parsed.category assert category_2 in parsed.category + def test_checks_parser_resource_group(self): + argument = "--resource-group" + resource_group = "storage" + command = [prowler_command, argument, resource_group] + parsed = self.parser.parse(command) + assert len(parsed.resource_group) == 1 + assert resource_group in parsed.resource_group + + def test_checks_parser_resource_groups_alias(self): + argument = "--resource-groups" + resource_group = "storage" + command = [prowler_command, argument, resource_group] + parsed = self.parser.parse(command) + assert len(parsed.resource_group) == 1 + assert resource_group in parsed.resource_group + + def test_checks_parser_resource_groups_two(self): + argument = "--resource-group" + resource_group_1 = "storage" + resource_group_2 = "compute" + command = [prowler_command, argument, resource_group_1, resource_group_2] + parsed = self.parser.parse(command) + assert len(parsed.resource_group) == 2 + assert resource_group_1 in parsed.resource_group + assert resource_group_2 in parsed.resource_group + def test_list_checks_parser_list_checks_short(self): argument = "-l" command = [prowler_command, argument] @@ -759,6 +793,12 @@ class Test_Parser: parsed = self.parser.parse(command) assert parsed.list_categories + def test_list_checks_parser_list_resource_groups(self): + argument = "--list-resource-groups" + command = [prowler_command, argument] + parsed = self.parser.parse(command) + assert parsed.list_resource_groups + def test_list_checks_parser_list_fixers(self): argument = "--list-fixers" command = [prowler_command, argument] diff --git a/tests/lib/cli/redact_test.py b/tests/lib/cli/redact_test.py new file mode 100644 index 0000000000..1f33998356 --- /dev/null +++ b/tests/lib/cli/redact_test.py @@ -0,0 +1,108 @@ +from unittest.mock import patch + +import pytest + +from prowler.lib.cli.redact import REDACTED_VALUE, get_sensitive_arguments, redact_argv + + +@pytest.fixture +def mock_sensitive_args(): + """Mock get_sensitive_arguments to return a known set.""" + sensitive = frozenset( + {"--shodan", "--personal-access-token", "--atlas-private-key"} + ) + with patch( + "prowler.lib.cli.redact.get_sensitive_arguments", return_value=sensitive + ): + yield sensitive + + +class TestRedactArgv: + def test_empty_argv(self, mock_sensitive_args): + assert redact_argv([]) == "" + + def test_no_sensitive_flags(self, mock_sensitive_args): + argv = ["aws", "--region", "eu-west-1", "--output-formats", "html"] + assert redact_argv(argv) == "aws --region eu-west-1 --output-formats html" + + def test_sensitive_flag_with_value(self, mock_sensitive_args): + argv = ["aws", "--shodan", "abc123"] + assert redact_argv(argv) == f"aws --shodan {REDACTED_VALUE}" + + def test_sensitive_flag_with_equals_syntax(self, mock_sensitive_args): + argv = ["aws", "--shodan=abc123"] + assert redact_argv(argv) == f"aws --shodan={REDACTED_VALUE}" + + def test_sensitive_flag_at_end_without_value(self, mock_sensitive_args): + argv = ["aws", "--shodan"] + assert redact_argv(argv) == "aws --shodan" + + def test_sensitive_flag_followed_by_another_flag(self, mock_sensitive_args): + argv = ["aws", "--shodan", "--region", "eu-west-1"] + # --region starts with '-', so --shodan value is not redacted (it has no value) + assert redact_argv(argv) == "aws --shodan --region eu-west-1" + + def test_multiple_sensitive_flags(self, mock_sensitive_args): + argv = [ + "github", + "--personal-access-token", + "ghp_secret123", + "--shodan", + "shodan_key", + ] + assert ( + redact_argv(argv) + == f"github --personal-access-token {REDACTED_VALUE} --shodan {REDACTED_VALUE}" + ) + + def test_mixed_sensitive_and_non_sensitive(self, mock_sensitive_args): + argv = [ + "mongodbatlas", + "--atlas-private-key", + "my_secret", + "--atlas-project-id", + "proj123", + ] + assert ( + redact_argv(argv) + == f"mongodbatlas --atlas-private-key {REDACTED_VALUE} --atlas-project-id proj123" + ) + + def test_sensitive_flag_equals_with_other_args(self, mock_sensitive_args): + argv = [ + "aws", + "--region", + "us-east-1", + "--shodan=key123", + "--output-formats", + "html", + ] + assert ( + redact_argv(argv) + == f"aws --region us-east-1 --shodan={REDACTED_VALUE} --output-formats html" + ) + + def test_non_sensitive_flag_with_equals(self, mock_sensitive_args): + argv = ["aws", "--region=us-east-1"] + assert redact_argv(argv) == "aws --region=us-east-1" + + +class TestGetSensitiveArguments: + def test_discovers_known_sensitive_arguments(self): + """Integration test: verify the discovery mechanism finds flags from provider modules.""" + get_sensitive_arguments.cache_clear() + result = get_sensitive_arguments() + assert "--shodan" in result + assert "--personal-access-token" in result + assert "--oauth-app-token" in result + assert "--atlas-private-key" in result + assert "--nhn-password" in result + assert "--os-password" in result + + def test_does_not_include_non_sensitive_flags(self): + """Verify non-sensitive flags are not in the set.""" + get_sensitive_arguments.cache_clear() + result = get_sensitive_arguments() + assert "--region" not in result + assert "--profile" not in result + assert "--output-formats" not in result diff --git a/tests/lib/outputs/asff/asff_test.py b/tests/lib/outputs/asff/asff_test.py index 5173b340d5..7895aeebdf 100644 --- a/tests/lib/outputs/asff/asff_test.py +++ b/tests/lib/outputs/asff/asff_test.py @@ -55,6 +55,8 @@ class TestASFF: ProductFields=ProductFields( ProviderVersion=prowler_version, ProwlerResourceName=finding.resource_uid, + ProwlerAccountOrganizationalUnitId=finding.account_ou_uid, + ProwlerAccountOrganizationalUnitName=finding.account_ou_name, ), GeneratorId="prowler-" + finding.metadata.CheckID, AwsAccountId=AWS_ACCOUNT_NUMBER, @@ -123,6 +125,8 @@ class TestASFF: ProductFields=ProductFields( ProviderVersion=prowler_version, ProwlerResourceName=finding.resource_uid, + ProwlerAccountOrganizationalUnitId=finding.account_ou_uid, + ProwlerAccountOrganizationalUnitName=finding.account_ou_name, ), GeneratorId="prowler-" + finding.metadata.CheckID, AwsAccountId=AWS_ACCOUNT_NUMBER, @@ -190,6 +194,8 @@ class TestASFF: ProductFields=ProductFields( ProviderVersion=prowler_version, ProwlerResourceName=finding.resource_uid, + ProwlerAccountOrganizationalUnitId=finding.account_ou_uid, + ProwlerAccountOrganizationalUnitName=finding.account_ou_name, ), GeneratorId="prowler-" + finding.metadata.CheckID, AwsAccountId=AWS_ACCOUNT_NUMBER, @@ -261,6 +267,8 @@ class TestASFF: ProductFields=ProductFields( ProviderVersion=prowler_version, ProwlerResourceName=finding.resource_uid, + ProwlerAccountOrganizationalUnitId=finding.account_ou_uid, + ProwlerAccountOrganizationalUnitName=finding.account_ou_name, ), GeneratorId="prowler-" + finding.metadata.CheckID, AwsAccountId=AWS_ACCOUNT_NUMBER, @@ -470,6 +478,8 @@ class TestASFF: ProductFields=ProductFields( ProviderVersion=prowler_version, ProwlerResourceName=finding.resource_uid, + ProwlerAccountOrganizationalUnitId=finding.account_ou_uid, + ProwlerAccountOrganizationalUnitName=finding.account_ou_name, ), GeneratorId="prowler-" + finding.metadata.CheckID, AwsAccountId=AWS_ACCOUNT_NUMBER, @@ -539,10 +549,14 @@ class TestASFF: "ProviderName": "Prowler", "ProviderVersion": prowler_version, "ProwlerResourceName": "test-arn", + "ProwlerAccountOrganizationalUnitId": "ou-abc1-12345678", + "ProwlerAccountOrganizationalUnitName": "Production/WebServices", }, "GeneratorId": "prowler-service_test_check_id", "AwsAccountId": "123456789012", - "Types": ["test-type"], + "Types": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], "FirstObservedAt": timestamp, "UpdatedAt": timestamp, "CreatedAt": timestamp, diff --git a/tests/lib/outputs/csv/csv_test.py b/tests/lib/outputs/csv/csv_test.py index 7b96f5ea89..b2b5d390df 100644 --- a/tests/lib/outputs/csv/csv_test.py +++ b/tests/lib/outputs/csv/csv_test.py @@ -29,15 +29,15 @@ class TestCSV: partition="aws", description="Description of the finding", risk="High", - related_url="http://example.com", + related_url="", remediation_recommendation_text="Recommendation text", - remediation_recommendation_url="http://example.com/remediation", + remediation_recommendation_url="https://hub.prowler.com/check/test_check", remediation_code_nativeiac="native-iac-code", remediation_code_terraform="terraform-code", remediation_code_other="other-code", remediation_code_cli="cli-code", compliance={"compliance_key": "compliance_value"}, - categories=["categorya", "categoryb"], + categories=["encryption", "logging"], depends_on=["dependency"], related_to=["related"], additional_urls=[ @@ -65,7 +65,10 @@ class TestCSV: assert output_data["PROVIDER"] == "aws" assert output_data["CHECK_ID"] == "service_test_check_id" assert output_data["CHECK_TITLE"] == "service_test_check_id" - assert output_data["CHECK_TYPE"] == "test-type" + assert ( + output_data["CHECK_TYPE"] + == "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ) assert isinstance(output_data["STATUS"], str) assert output_data["STATUS"] == "PASS" assert output_data["STATUS_EXTENDED"] == "status-extended" @@ -86,11 +89,11 @@ class TestCSV: assert output_data["REGION"] == AWS_REGION_EU_WEST_1 assert output_data["DESCRIPTION"] == "Description of the finding" assert output_data["RISK"] == "High" - assert output_data["RELATED_URL"] == "http://example.com" + assert output_data["RELATED_URL"] == "" assert output_data["REMEDIATION_RECOMMENDATION_TEXT"] == "Recommendation text" assert ( output_data["REMEDIATION_RECOMMENDATION_URL"] - == "http://example.com/remediation" + == "https://hub.prowler.com/check/test_check" ) assert output_data["REMEDIATION_CODE_NATIVEIAC"] == "native-iac-code" assert output_data["REMEDIATION_CODE_TERRAFORM"] == "terraform-code" @@ -98,7 +101,7 @@ class TestCSV: assert output_data["REMEDIATION_CODE_OTHER"] == "other-code" assert isinstance(output_data["COMPLIANCE"], str) assert output_data["COMPLIANCE"] == "compliance_key: compliance_value" - assert output_data["CATEGORIES"] == "categorya | categoryb" + assert output_data["CATEGORIES"] == "encryption | logging" assert output_data["DEPENDS_ON"] == "dependency" assert output_data["RELATED_TO"] == "related" assert ( @@ -107,6 +110,8 @@ class TestCSV: ) assert output_data["NOTES"] == "Notes about the finding" assert output_data["PROWLER_VERSION"] == prowler_version + assert output_data["ACCOUNT_OU_UID"] == "ou-abc1-12345678" + assert output_data["ACCOUNT_OU_NAME"] == "Production/WebServices" @freeze_time(datetime.now()) def test_csv_write_to_file(self): @@ -121,7 +126,7 @@ class TestCSV: output.batch_write_data_to_file() mock_file.seek(0) - expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\r\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\r\n" + expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS;ACCOUNT_OU_UID;ACCOUNT_OU_NAME\r\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;Software and Configuration Checks/AWS Security Best Practices/Network Reachability;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;;;;;;;;test-compliance: test-compliance;encryption;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html;ou-abc1-12345678;Production/WebServices\r\n" content = mock_file.read() assert content == expected_csv @@ -199,7 +204,7 @@ class TestCSV: with patch.object(temp_file, "close", return_value=None): csv.batch_write_data_to_file() - expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\n" + expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS;ACCOUNT_OU_UID;ACCOUNT_OU_NAME\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;Software and Configuration Checks/AWS Security Best Practices/Network Reachability;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;;;;;;;;test-compliance: test-compliance;encryption;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html;ou-abc1-12345678;Production/WebServices\n" temp_file.seek(0) diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index 566824c82f..00ce2fe1f7 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -151,6 +151,8 @@ class TestFinding: provider.organizations_metadata.organization_arn = "mock_account_org_uid" provider.organizations_metadata.organization_id = "mock_account_org_name" provider.organizations_metadata.account_tags = {"tag1": "value1"} + provider.organizations_metadata.account_ou_id = "ou-test-12345678" + provider.organizations_metadata.account_ou_name = "TestOU/SubOU" # Mock check result check_output = MagicMock() @@ -204,6 +206,8 @@ class TestFinding: assert finding_output.account_email == "mock_account_email" assert finding_output.account_organization_uid == "mock_account_org_uid" assert finding_output.account_organization_name == "mock_account_org_name" + assert finding_output.account_ou_uid == "ou-test-12345678" + assert finding_output.account_ou_name == "TestOU/SubOU" assert finding_output.account_tags == {"tag1": "value1"} # Metadata @@ -241,6 +245,45 @@ class TestFinding: assert finding_output.service_name == "service" assert finding_output.raw == {} + def test_generate_output_aws_without_organizations_metadata(self): + # Simulates running without --organizations-role + provider = MagicMock() + provider.type = "aws" + provider.identity.profile = "mock_auth" + provider.identity.account = "mock_account_uid" + provider.identity.partition = "aws" + provider.organizations_metadata = None + + check_output = MagicMock() + check_output.resource_id = "test_resource_id" + check_output.resource_arn = "test_resource_arn" + check_output.resource_details = "test_resource_details" + check_output.resource_tags = {} + check_output.region = "us-east-1" + check_output.partition = "aws" + check_output.status = Status.PASS + check_output.status_extended = "mock_status_extended" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="aws") + check_output.resource = {} + check_output.compliance = {} + + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert isinstance(finding_output, Finding) + assert finding_output.account_uid == "mock_account_uid" + # get_nested_attribute returns empty string when the attribute chain + # is None, so the Finding fields are "" not None + assert finding_output.account_name == "" + assert finding_output.account_email == "" + assert finding_output.account_organization_uid == "" + assert finding_output.account_organization_name == "" + assert finding_output.account_ou_uid == "" + assert finding_output.account_ou_name == "" + def test_generate_output_azure(self): # Mock provider provider = MagicMock() @@ -514,6 +557,7 @@ class TestFinding: assert finding_output.resource_tags == {} assert finding_output.partition is None assert finding_output.account_uid == "test_cluster" + assert finding_output.provider_uid == "In-Cluster" assert finding_output.account_name == "context: In-Cluster" assert finding_output.account_email is None assert finding_output.account_organization_uid is None @@ -722,6 +766,10 @@ class TestFinding: assert finding_output.resource_name == "aws_s3_bucket.example" assert finding_output.resource_uid == "aws_s3_bucket.example" assert finding_output.region == "main" # Branch name, not line range + assert ( + finding_output.uid + == "prowler-iac-service_check_id-iac-main-aws_s3_bucket.example-1:5" + ) assert finding_output.status == Status.PASS assert finding_output.status_extended == "mock_status_extended" assert finding_output.muted is False @@ -736,6 +784,35 @@ class TestFinding: assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" + def test_generate_output_iac_empty_line_range(self): + provider = MagicMock() + provider.type = "iac" + provider.provider_uid = None + provider.scan_repository_url = "https://github.com/user/repo" + provider.auth_method = "No auth" + + check_output = MagicMock() + check_output.file_path = "/path/to/iac/main.tf" + check_output.resource_name = "main.tf" + check_output.resource_path = "/path/to/iac/main.tf" + check_output.resource_line_range = "" + check_output.region = "main" + check_output.resource = {"resource": "main.tf", "value": {}} + check_output.resource_details = "" + check_output.status = Status.PASS + check_output.status_extended = "No issues found" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="iac") + check_output.compliance = {} + + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert isinstance(finding_output, Finding) + assert finding_output.uid == "prowler-iac-service_check_id-iac-main-main.tf" + def assert_keys_lowercase(self, d): for k, v in d.items(): assert k.islower() @@ -765,6 +842,8 @@ class TestFinding: provider.organizations_metadata.organization_arn = "mock_account_org_uid" provider.organizations_metadata.organization_id = "mock_account_org_name" provider.organizations_metadata.account_tags = {"tag1": "value1"} + provider.organizations_metadata.account_ou_id = "" + provider.organizations_metadata.account_ou_name = "" # Mock check result check_output = MagicMock() @@ -831,16 +910,19 @@ class TestFinding: "provider": "test_provider", "checkid": "service_check_001", "checktitle": "Test Check", - "checktype": ["type1"], + "checktype": [], "servicename": "service", "subservicename": "SubService", "severity": "high", "resourcetype": "TestResource", "description": "A test check", "risk": "High risk", - "relatedurl": "http://example.com", + "relatedurl": "", "remediation": { - "recommendation": {"text": "Fix it", "url": "http://fix.com"}, + "recommendation": { + "text": "Fix it", + "url": "https://hub.prowler.com/check/service_check_001", + }, "code": { "nativeiac": "iac_code", "terraform": "terraform_code", @@ -849,7 +931,7 @@ class TestFinding: }, }, "resourceidtemplate": "template", - "categories": ["cat-one", "cat-two"], + "categories": ["encryption", "logging"], "dependson": ["dep1"], "relatedto": ["rel1"], "notes": "Some notes", @@ -874,22 +956,25 @@ class TestFinding: assert meta.Provider == "test_provider" assert meta.CheckID == "service_check_001" assert meta.CheckTitle == "Test Check" - assert meta.CheckType == ["type1"] + assert meta.CheckType == [] assert meta.ServiceName == "service" assert meta.SubServiceName == "SubService" assert meta.Severity == "high" assert meta.ResourceType == "TestResource" assert meta.Description == "A test check" assert meta.Risk == "High risk" - assert meta.RelatedUrl == "http://example.com" + assert meta.RelatedUrl == "" assert meta.Remediation.Recommendation.Text == "Fix it" - assert meta.Remediation.Recommendation.Url == "http://fix.com" + assert ( + meta.Remediation.Recommendation.Url + == "https://hub.prowler.com/check/service_check_001" + ) assert meta.Remediation.Code.NativeIaC == "iac_code" assert meta.Remediation.Code.Terraform == "terraform_code" assert meta.Remediation.Code.CLI == "cli_code" assert meta.Remediation.Code.Other == "other_code" assert meta.ResourceIdTemplate == "template" - assert meta.Categories == ["cat-one", "cat-two"] + assert meta.Categories == ["encryption", "logging"] assert meta.DependsOn == ["dep1"] assert meta.RelatedTo == ["rel1"] assert meta.Notes == "Some notes" @@ -955,11 +1040,11 @@ class TestFinding: "dependson": [], "relatedto": [], "categories": [], - "checktitle": "Ensure that Auto provisioning of 'Log Analytics agent for Azure VMs' is Set to 'On'", + "checktitle": "Auto provisioning of Log Analytics agent for Azure VMs should be On", "compliance": None, - "relatedurl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-data-security", + "relatedurl": "", "description": ( - "Ensure that Auto provisioning of 'Log Analytics agent for Azure VMs' is Set to 'On'. " + "Auto provisioning of Log Analytics agent for Azure VMs should be On. " "The Microsoft Monitoring Agent scans for various security-related configurations and events such as system updates, " "OS vulnerabilities, endpoint protection, and provides alerts." ), @@ -971,9 +1056,9 @@ class TestFinding: "terraform": "", }, "recommendation": { - "url": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/monitoring-components", + "url": "https://hub.prowler.com/check/defender_auto_provisioning_log_analytics_agent_vms_on", "text": ( - "Ensure comprehensive visibility into possible security vulnerabilities, including missing updates, " + "Comprehensive visibility into possible security vulnerabilities, including missing updates, " "misconfigured operating system security settings, and active threats, allowing for timely mitigation and improved overall security posture" ), }, @@ -1020,16 +1105,16 @@ class TestFinding: assert meta.CheckID == "defender_auto_provisioning_log_analytics_agent_vms_on" assert ( meta.CheckTitle - == "Ensure that Auto provisioning of 'Log Analytics agent for Azure VMs' is Set to 'On'" + == "Auto provisioning of Log Analytics agent for Azure VMs should be On" ) assert meta.Severity == "medium" assert meta.ResourceType == "AzureDefenderPlan" assert ( meta.Remediation.Recommendation.Url - == "https://learn.microsoft.com/en-us/azure/defender-for-cloud/monitoring-components" + == "https://hub.prowler.com/check/defender_auto_provisioning_log_analytics_agent_vms_on" ) assert meta.Remediation.Recommendation.Text.startswith( - "Ensure comprehensive visibility" + "Comprehensive visibility" ) expected_segments = [ @@ -1077,7 +1162,7 @@ class TestFinding: "resourcetype": "GCPResourceType", "description": "GCP check description", "risk": "Medium risk", - "relatedurl": "http://gcp.example.com", + "relatedurl": "", "remediation": { "code": { "nativeiac": "iac_code", @@ -1085,10 +1170,13 @@ class TestFinding: "cli": "cli_code", "other": "other_code", }, - "recommendation": {"text": "Fix it", "url": "http://fix-gcp.com"}, + "recommendation": { + "text": "Fix it", + "url": "https://hub.prowler.com/check/service_gcp_check_001", + }, }, "resourceidtemplate": "template", - "categories": ["cat-one", "cat-two"], + "categories": ["encryption", "logging"], "dependson": ["dep1"], "relatedto": ["rel1"], "notes": "Some notes", @@ -1157,7 +1245,7 @@ class TestFinding: "resourcetype": "K8sResourceType", "description": "K8s check description", "risk": "Low risk", - "relatedurl": "http://k8s.example.com", + "relatedurl": "", "remediation": { "code": { "nativeiac": "iac_code", @@ -1165,10 +1253,13 @@ class TestFinding: "cli": "cli_code", "other": "other_code", }, - "recommendation": {"text": "Fix it", "url": "http://fix-k8s.com"}, + "recommendation": { + "text": "Fix it", + "url": "https://hub.prowler.com/check/service_k8s_check_001", + }, }, "resourceidtemplate": "template", - "categories": ["cat-one"], + "categories": ["encryption"], "dependson": [], "relatedto": [], "notes": "K8s notes", @@ -1223,7 +1314,7 @@ class TestFinding: "resourcetype": "M365ResourceType", "description": "M365 check description", "risk": "High risk", - "relatedurl": "http://m365.example.com", + "relatedurl": "", "remediation": { "code": { "nativeiac": "iac_code", @@ -1231,10 +1322,13 @@ class TestFinding: "cli": "cli_code", "other": "other_code", }, - "recommendation": {"text": "Fix it", "url": "http://fix-m365.com"}, + "recommendation": { + "text": "Fix it", + "url": "https://hub.prowler.com/check/service_m365_check_001", + }, }, "resourceidtemplate": "template", - "categories": ["cat-one"], + "categories": ["encryption"], "dependson": [], "relatedto": [], "notes": "M365 notes", diff --git a/tests/lib/outputs/fixtures/fixtures.py b/tests/lib/outputs/fixtures/fixtures.py index 3f6dfc2815..3163f161c7 100644 --- a/tests/lib/outputs/fixtures/fixtures.py +++ b/tests/lib/outputs/fixtures/fixtures.py @@ -25,14 +25,14 @@ def generate_finding_output( partition: str = "aws", description: str = "check description", risk: str = "test-risk", - related_url: str = "test-url", + related_url: str = "", remediation_recommendation_text: str = "", remediation_recommendation_url: str = "", remediation_code_nativeiac: str = "", remediation_code_terraform: str = "", remediation_code_cli: str = "", remediation_code_other: str = "", - categories: list[str] = ["test-category"], + categories: list[str] = ["encryption"], depends_on: list[str] = ["test-dependency"], related_to: list[str] = ["test-related-to"], notes: str = "test-notes", @@ -43,17 +43,31 @@ def generate_finding_output( service_name: str = "service", check_id: str = "service_test_check_id", check_title: str = "service_test_check_id", - check_type: list[str] = ["test-type"], + check_type: list[str] = None, + provider_uid: str = None, + account_ou_uid: str = "ou-abc1-12345678", + account_ou_name: str = "Production/WebServices", ) -> Finding: + if check_type is None: + check_type = ( + [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ] + if provider == "aws" + else [] + ) return Finding( auth_method="profile: default", timestamp=timestamp if timestamp else datetime.now(), account_uid=account_uid, + provider_uid=provider_uid, account_name=account_name, account_email="", account_organization_uid="test-organization-id", account_organization_name="test-organization", account_tags={"test-tag": "test-value"}, + account_ou_uid=account_ou_uid, + account_ou_name=account_ou_name, uid="test-unique-finding", status=status, status_extended=status_extended, diff --git a/tests/lib/outputs/fixtures/metadata.json b/tests/lib/outputs/fixtures/metadata.json index a376d491a9..b26438aac8 100644 --- a/tests/lib/outputs/fixtures/metadata.json +++ b/tests/lib/outputs/fixtures/metadata.json @@ -1,10 +1,10 @@ { "Categories": [ - "cat-one", - "cat-two" + "encryption", + "logging" ], "CheckID": "iam_user_accesskey_unused", - "CheckTitle": "Ensure Access Keys unused are disabled", + "CheckTitle": "Access Keys unused should be disabled", "CheckType": [ "Software and Configuration Checks" ], @@ -25,14 +25,14 @@ "othercheck1", "othercheck2" ], - "Description": "Ensure Access Keys unused are disabled", + "Description": "Access Keys unused should be disabled", "Notes": "additional information", "Provider": "aws", "RelatedTo": [ "othercheck3", "othercheck4" ], - "RelatedUrl": "https://serviceofficialsiteorpageforthissubject", + "RelatedUrl": "", "Remediation": { "Code": { "CLI": "cli command or URL to the cli command location.", @@ -42,7 +42,7 @@ }, "Recommendation": { "Text": "Run sudo yum update and cross your fingers and toes.", - "Url": "https://myfp.com/recommendations/dangerous_things_and_how_to_fix_them.html" + "Url": "https://hub.prowler.com/check/iam_user_accesskey_unused" } }, "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 7bb5256d81..c8d4ac199d 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -4,6 +4,7 @@ from io import StringIO from mock import MagicMock, patch from prowler.config.config import prowler_version, timestamp +from prowler.lib.cli.redact import redact_argv from prowler.lib.logger import logger from prowler.lib.outputs.html.html import HTML from prowler.providers.github.models import GithubAppIdentityInfo @@ -473,7 +474,7 @@ def get_aws_html_header(args: list) -> str:
  • - Parameters used: {" ".join(args)} + Parameters used: {redact_argv(args)}
  • Date: {timestamp.isoformat()} diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 5e93d5c7d0..03656891c8 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -345,11 +345,19 @@ class TestJiraIntegration: "get_projects", return_value={"PROJ1": "Project One", "PROJ2": "Project Two"}, ) - def test_test_connection_successful(self, mock_get_projects, mock_get_auth): + @patch.object( + Jira, + "get_available_issue_types", + side_effect=lambda pk: ["Task", "Bug"] if pk == "PROJ1" else ["Story"], + ) + def test_test_connection_successful( + self, mock_get_issue_types, mock_get_projects, mock_get_auth + ): """Test that a successful connection returns an active Connection object with projects.""" # To disable vulture mock_get_projects = mock_get_projects mock_get_auth = mock_get_auth + mock_get_issue_types = mock_get_issue_types connection = Jira.test_connection( redirect_uri=self.redirect_uri, @@ -360,6 +368,10 @@ class TestJiraIntegration: assert connection.is_connected assert connection.error is None assert connection.projects == {"PROJ1": "Project One", "PROJ2": "Project Two"} + assert connection.issue_types == { + "PROJ1": ["Task", "Bug"], + "PROJ2": ["Story"], + } @patch.object(Jira, "get_basic_auth", return_value=None) @patch.object( @@ -367,13 +379,19 @@ class TestJiraIntegration: "get_projects", return_value={"PROJ1": "Project One", "PROJ2": "Project Two"}, ) + @patch.object( + Jira, + "get_available_issue_types", + side_effect=lambda pk: ["Task", "Bug"] if pk == "PROJ1" else ["Story"], + ) def test_test_connection_successful_basic_auth( - self, mock_get_projects, mock_get_basic_auth + self, mock_get_issue_types, mock_get_projects, mock_get_basic_auth ): """Test that a successful connection returns an active Connection object with projects.""" # To disable vulture mock_get_projects = mock_get_projects mock_get_basic_auth = mock_get_basic_auth + mock_get_issue_types = mock_get_issue_types connection = Jira.test_connection( user_mail=self.user_mail, @@ -384,6 +402,10 @@ class TestJiraIntegration: assert connection.is_connected assert connection.error is None assert connection.projects == {"PROJ1": "Project One", "PROJ2": "Project Two"} + assert connection.issue_types == { + "PROJ1": ["Task", "Bug"], + "PROJ2": ["Story"], + } @patch.object( Jira, diff --git a/tests/lib/outputs/ocsf/ocsf_test.py b/tests/lib/outputs/ocsf/ocsf_test.py index 3199443b13..16ff1ce317 100644 --- a/tests/lib/outputs/ocsf/ocsf_test.py +++ b/tests/lib/outputs/ocsf/ocsf_test.py @@ -65,7 +65,9 @@ class TestOCSF: assert output_data.finding_info.desc == findings[0].metadata.Description assert output_data.finding_info.title == findings[0].metadata.CheckTitle assert output_data.finding_info.uid == findings[0].uid - assert output_data.finding_info.types == ["test-type"] + assert output_data.finding_info.types == [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ] assert output_data.time == int(findings[0].timestamp.timestamp()) assert output_data.time_dt == findings[0].timestamp assert ( @@ -113,6 +115,8 @@ class TestOCSF: "additional_urls": findings[0].metadata.AdditionalURLs, "notes": findings[0].metadata.Notes, "compliance": findings[0].compliance, + "provider_uid": findings[0].account_uid, + "provider": findings[0].provider, } # Test with int timestamp (UNIX timestamp) @@ -209,8 +213,8 @@ class TestOCSF: "status_detail": "status extended", "status_id": 1, "unmapped": { - "related_url": "test-url", - "categories": ["test-category"], + "related_url": "", + "categories": ["encryption"], "depends_on": ["test-dependency"], "related_to": ["test-related-to"], "additional_urls": [ @@ -219,6 +223,8 @@ class TestOCSF: ], "notes": "test-notes", "compliance": {"test-compliance": "test-compliance"}, + "provider_uid": "123456789012", + "provider": "aws", }, "activity_name": "Create", "activity_id": 1, @@ -228,7 +234,9 @@ class TestOCSF: "desc": "check description", "title": "service_test_check_id", "uid": "test-unique-finding", - "types": ["test-type"], + "types": [ + "Software and Configuration Checks/AWS Security Best Practices/Network Reachability" + ], }, "resources": [ { @@ -260,6 +268,8 @@ class TestOCSF: "org": { "name": "test-organization", "uid": "test-organization-id", + "ou_uid": "ou-abc1-12345678", + "ou_name": "Production/WebServices", }, "provider": "aws", "region": "eu-west-1", @@ -354,6 +364,8 @@ class TestOCSF: "additional_urls": finding_output.metadata.AdditionalURLs, "notes": finding_output.metadata.Notes, "compliance": finding_output.compliance, + "provider_uid": finding_output.account_uid, + "provider": finding_output.provider, } # ResourceDetails @@ -416,6 +428,8 @@ class TestOCSF: assert isinstance(cloud_organization, Organization) assert cloud_organization.uid == finding_output.account_organization_uid assert cloud_organization.name == finding_output.account_organization_name + assert cloud_organization.ou_uid == finding_output.account_ou_uid + assert cloud_organization.ou_name == finding_output.account_ou_name def test_finding_output_kubernetes(self): finding_output = generate_finding_output( @@ -424,6 +438,7 @@ class TestOCSF: muted=True, region=AWS_REGION_EU_WEST_1, provider="kubernetes", + provider_uid="test-k8s-context", ) finding_ocsf = OCSF([finding_output]) @@ -433,6 +448,8 @@ class TestOCSF: assert finding_ocsf.resources[0].namespace == finding_output.region.replace( "namespace: ", "" ) + assert finding_ocsf.unmapped["provider_uid"] == "test-k8s-context" + assert finding_ocsf.unmapped["provider"] == "kubernetes" def test_finding_output_cloud_fail_low_not_muted(self): finding_output = generate_finding_output( diff --git a/tests/lib/scan/scan_test.py b/tests/lib/scan/scan_test.py index e6ff948ed7..b0fe82b7b2 100644 --- a/tests/lib/scan/scan_test.py +++ b/tests/lib/scan/scan_test.py @@ -27,15 +27,15 @@ finding = generate_finding_output( partition="aws", description="Description of the finding", risk="High", - related_url="http://example.com", + related_url="", remediation_recommendation_text="Recommendation text", - remediation_recommendation_url="http://example.com/remediation", + remediation_recommendation_url="https://hub.prowler.com/check/test_check", remediation_code_nativeiac="native-iac-code", remediation_code_terraform="terraform-code", remediation_code_other="other-code", remediation_code_cli="cli-code", compliance={"compliance_key": "compliance_value"}, - categories=["categorya", "categoryb"], + categories=["encryption", "logging"], depends_on=["dependency"], related_to=["related"], notes="Notes about the finding", diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index 81ae5b218c..68342a2735 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -943,6 +943,68 @@ aws: aws_provider._identity.profile_region = "non-existent-region" assert aws_provider.get_default_region("ec2") == AWS_REGION_EU_WEST_1 + @mock_aws + def test_get_default_region_global_service_ignores_profile_region(self): + region = [AWS_REGION_EU_WEST_1] + aws_provider = AwsProvider( + regions=region, + ) + aws_provider._identity.profile_region = AWS_REGION_EU_WEST_1 + + assert ( + aws_provider.get_default_region("cloudfront", global_service=True) + == AWS_REGION_US_EAST_1 + ) + + @mock_aws + def test_get_default_region_global_service_ignores_audited_regions(self): + region = [AWS_REGION_EU_WEST_1] + aws_provider = AwsProvider( + regions=region, + ) + aws_provider._identity.profile_region = None + + assert ( + aws_provider.get_default_region("route53", global_service=True) + == AWS_REGION_US_EAST_1 + ) + + @mock_aws + def test_get_default_region_global_service_china_partition(self): + aws_provider = AwsProvider() + aws_provider._identity.partition = AWS_CHINA_PARTITION + aws_provider._identity.profile_region = AWS_REGION_CN_NORTHWEST_1 + + assert ( + aws_provider.get_default_region("cloudfront", global_service=True) + == AWS_REGION_CN_NORTH_1 + ) + + @mock_aws + def test_get_default_region_global_service_gov_cloud_partition(self): + aws_provider = AwsProvider() + aws_provider._identity.partition = AWS_GOV_CLOUD_PARTITION + aws_provider._identity.profile_region = "us-gov-west-1" + + assert ( + aws_provider.get_default_region("shield", global_service=True) + == AWS_REGION_GOV_CLOUD_US_EAST_1 + ) + + @mock_aws + def test_get_default_region_non_global_service_unaffected(self): + """Ensure global_service=False (default) still follows profile region logic.""" + region = [AWS_REGION_EU_WEST_1] + aws_provider = AwsProvider( + regions=region, + ) + aws_provider._identity.profile_region = AWS_REGION_EU_WEST_1 + + assert ( + aws_provider.get_default_region("ec2", global_service=False) + == AWS_REGION_EU_WEST_1 + ) + @mock_aws def test_aws_gov_get_global_region(self): aws_provider = AwsProvider() diff --git a/tests/providers/aws/lib/organizations/organizations_test.py b/tests/providers/aws/lib/organizations/organizations_test.py index ad6667bca4..8c0def64ac 100644 --- a/tests/providers/aws/lib/organizations/organizations_test.py +++ b/tests/providers/aws/lib/organizations/organizations_test.py @@ -1,7 +1,11 @@ +from unittest.mock import MagicMock + import boto3 +from botocore.exceptions import ClientError from moto import mock_aws from prowler.providers.aws.lib.organizations.organizations import ( + _get_ou_metadata, get_organizations_metadata, parse_organizations_metadata, ) @@ -27,8 +31,10 @@ class Test_AWS_Organizations: ResourceId=account_id, Tags=[{"Key": "key", "Value": "value"}] ) - metadata, tags = get_organizations_metadata(account_id, boto3.Session()) - org = parse_organizations_metadata(metadata, tags) + metadata, tags, ou_metadata = get_organizations_metadata( + account_id, boto3.Session() + ) + org = parse_organizations_metadata(metadata, tags, ou_metadata) assert isinstance(org, AWSOrganizationsInfo) assert org.account_email == mockemail @@ -43,6 +49,8 @@ class Test_AWS_Organizations: ) assert org.organization_id == org_id assert org.account_tags == {"key": "value"} + assert org.account_ou_id == "" + assert org.account_ou_name == "" def test_parse_organizations_metadata(self): tags = {"Tags": [{"Key": "test-key", "Value": "test-value"}]} @@ -71,3 +79,244 @@ class Test_AWS_Organizations: ) assert org.organization_arn == arn assert org.account_tags == {"test-key": "test-value"} + assert org.account_ou_id == "" + assert org.account_ou_name == "" + + @mock_aws + def test_organizations_with_ou(self): + client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1) + + client.create_organization(FeatureSet="ALL") + account_id = client.create_account( + AccountName="ou-account", Email="ou@example.org" + )["CreateAccountStatus"]["AccountId"] + + root_id = client.list_roots()["Roots"][0]["Id"] + ou = client.create_organizational_unit(ParentId=root_id, Name="SecurityOU")[ + "OrganizationalUnit" + ] + + client.move_account( + AccountId=account_id, + SourceParentId=root_id, + DestinationParentId=ou["Id"], + ) + + metadata, tags, ou_metadata = get_organizations_metadata( + account_id, boto3.Session() + ) + org = parse_organizations_metadata(metadata, tags, ou_metadata) + + assert org.account_ou_id == ou["Id"] + assert org.account_ou_name == "SecurityOU" + + @mock_aws + def test_organizations_with_nested_ou(self): + client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1) + + client.create_organization(FeatureSet="ALL") + account_id = client.create_account( + AccountName="nested-account", Email="nested@example.org" + )["CreateAccountStatus"]["AccountId"] + + root_id = client.list_roots()["Roots"][0]["Id"] + parent_ou = client.create_organizational_unit( + ParentId=root_id, Name="Infrastructure" + )["OrganizationalUnit"] + child_ou = client.create_organizational_unit( + ParentId=parent_ou["Id"], Name="Security" + )["OrganizationalUnit"] + + client.move_account( + AccountId=account_id, + SourceParentId=root_id, + DestinationParentId=child_ou["Id"], + ) + + metadata, tags, ou_metadata = get_organizations_metadata( + account_id, boto3.Session() + ) + org = parse_organizations_metadata(metadata, tags, ou_metadata) + + assert org.account_ou_id == child_ou["Id"] + assert org.account_ou_name == "Infrastructure/Security" + + def test_parse_organizations_metadata_with_ou(self): + tags = {"Tags": []} + metadata = { + "Account": { + "Id": AWS_ACCOUNT_NUMBER, + "Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}", + "Email": "test@example.org", + "Name": "test-account", + "Status": "ACTIVE", + } + } + ou_metadata = {"ou_id": "ou-xxxx-12345678", "ou_path": "Infra/Security"} + + org = parse_organizations_metadata(metadata, tags, ou_metadata) + + assert org.account_ou_id == "ou-xxxx-12345678" + assert org.account_ou_name == "Infra/Security" + + def test_get_ou_metadata_api_error_returns_empty_dict(self): + client = MagicMock() + client.list_parents.side_effect = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "Access denied"}}, + "ListParents", + ) + + result = _get_ou_metadata(client, "123456789012") + + assert result == {} + + def test_get_ou_metadata_describe_ou_error_returns_empty_dict(self): + client = MagicMock() + client.list_parents.return_value = { + "Parents": [{"Id": "ou-xxxx-12345678", "Type": "ORGANIZATIONAL_UNIT"}] + } + client.describe_organizational_unit.side_effect = ClientError( + { + "Error": { + "Code": "OrganizationalUnitNotFoundException", + "Message": "OU not found", + } + }, + "DescribeOrganizationalUnit", + ) + + result = _get_ou_metadata(client, "123456789012") + + assert result == {} + + def test_get_ou_metadata_deeply_nested_three_levels(self): + client = MagicMock() + # First call: account's parent is child OU + # Second call: child OU's parent is mid OU + # Third call: mid OU's parent is top OU + # Fourth call: top OU's parent is ROOT + client.list_parents.side_effect = [ + {"Parents": [{"Id": "ou-child", "Type": "ORGANIZATIONAL_UNIT"}]}, + {"Parents": [{"Id": "ou-mid", "Type": "ORGANIZATIONAL_UNIT"}]}, + {"Parents": [{"Id": "ou-top", "Type": "ORGANIZATIONAL_UNIT"}]}, + {"Parents": [{"Id": "r-root", "Type": "ROOT"}]}, + ] + client.describe_organizational_unit.side_effect = [ + {"OrganizationalUnit": {"Id": "ou-child", "Name": "NonProd"}}, + {"OrganizationalUnit": {"Id": "ou-mid", "Name": "Workloads"}}, + {"OrganizationalUnit": {"Id": "ou-top", "Name": "Root"}}, + ] + + result = _get_ou_metadata(client, "123456789012") + + assert result == {"ou_id": "ou-child", "ou_path": "Root/Workloads/NonProd"} + + @mock_aws + def test_get_organizations_metadata_api_failure_returns_empty_tuples(self): + # Use a non-existent account ID without creating an organization + metadata, tags, ou_metadata = get_organizations_metadata( + "999999999999", boto3.Session() + ) + + assert metadata == {} + assert tags == {} + assert ou_metadata == {} + + def test_parse_organizations_metadata_with_empty_ou_metadata(self): + tags = {"Tags": []} + metadata = { + "Account": { + "Id": AWS_ACCOUNT_NUMBER, + "Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}", + "Email": "test@example.org", + "Name": "test-account", + "Status": "ACTIVE", + } + } + # Simulates the error path where _get_ou_metadata returns {} + ou_metadata = {} + + org = parse_organizations_metadata(metadata, tags, ou_metadata) + + assert org.account_ou_id == "" + assert org.account_ou_name == "" + + def test_parse_organizations_metadata_with_none_ou_metadata(self): + tags = {"Tags": []} + metadata = { + "Account": { + "Id": AWS_ACCOUNT_NUMBER, + "Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}", + "Email": "test@example.org", + "Name": "test-account", + "Status": "ACTIVE", + } + } + + org = parse_organizations_metadata(metadata, tags, None) + + assert org.account_ou_id == "" + assert org.account_ou_name == "" + + @mock_aws + def test_end_to_end_ou_metadata_flows_to_organizations_info(self): + """Integration test: exercises get_organizations_metadata → + parse_organizations_metadata with a nested OU, verifying the full + data flow that AwsProvider.get_organizations_info relies on.""" + client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1) + + client.create_organization(FeatureSet="ALL") + account_id = client.create_account( + AccountName="e2e-account", Email="e2e@example.org" + )["CreateAccountStatus"]["AccountId"] + + root_id = client.list_roots()["Roots"][0]["Id"] + top_ou = client.create_organizational_unit(ParentId=root_id, Name="Workloads")[ + "OrganizationalUnit" + ] + child_ou = client.create_organizational_unit( + ParentId=top_ou["Id"], Name="NonProd" + )["OrganizationalUnit"] + + client.move_account( + AccountId=account_id, + SourceParentId=root_id, + DestinationParentId=child_ou["Id"], + ) + client.tag_resource( + ResourceId=account_id, + Tags=[{"Key": "Environment", "Value": "dev"}], + ) + + # Full flow: get → parse → AWSOrganizationsInfo + metadata, tags, ou_metadata = get_organizations_metadata( + account_id, boto3.Session() + ) + org = parse_organizations_metadata(metadata, tags, ou_metadata) + + assert isinstance(org, AWSOrganizationsInfo) + assert org.account_name == "e2e-account" + assert org.account_email == "e2e@example.org" + assert org.account_tags == {"Environment": "dev"} + assert org.account_ou_id == child_ou["Id"] + assert org.account_ou_name == "Workloads/NonProd" + + @mock_aws + def test_end_to_end_account_under_root_has_empty_ou(self): + """Integration test: account directly under Root should produce + empty OU fields, not errors.""" + client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1) + + client.create_organization(FeatureSet="ALL") + account_id = client.create_account( + AccountName="root-account", Email="root@example.org" + )["CreateAccountStatus"]["AccountId"] + + metadata, tags, ou_metadata = get_organizations_metadata( + account_id, boto3.Session() + ) + org = parse_organizations_metadata(metadata, tags, ou_metadata) + + assert isinstance(org, AWSOrganizationsInfo) + assert org.account_ou_id == "" + assert org.account_ou_name == "" diff --git a/tests/providers/aws/lib/s3/s3_test.py b/tests/providers/aws/lib/s3/s3_test.py index b639a847cd..6112c467b2 100644 --- a/tests/providers/aws/lib/s3/s3_test.py +++ b/tests/providers/aws/lib/s3/s3_test.py @@ -39,15 +39,15 @@ FINDING = generate_finding_output( partition="aws", description="Description of the finding", risk="High", - related_url="http://example.com", + related_url="", remediation_recommendation_text="Recommendation text", - remediation_recommendation_url="http://example.com/remediation", + remediation_recommendation_url="", remediation_code_nativeiac="native-iac-code", remediation_code_terraform="terraform-code", remediation_code_other="other-code", remediation_code_cli="cli-code", compliance={"compliance_key": "compliance_value"}, - categories=["categorya", "categoryb"], + categories=["encryption", "logging"], depends_on=["dependency"], related_to=["related"], notes="Notes about the finding", diff --git a/tests/providers/aws/lib/service/service_test.py b/tests/providers/aws/lib/service/service_test.py index 8269c663f5..ea6768bdc1 100644 --- a/tests/providers/aws/lib/service/service_test.py +++ b/tests/providers/aws/lib/service/service_test.py @@ -4,7 +4,13 @@ from prowler.providers.aws.lib.service.service import AWSService from tests.providers.aws.utils import ( AWS_ACCOUNT_ARN, AWS_ACCOUNT_NUMBER, + AWS_CHINA_PARTITION, AWS_COMMERCIAL_PARTITION, + AWS_GOV_CLOUD_PARTITION, + AWS_REGION_CN_NORTH_1, + AWS_REGION_CN_NORTHWEST_1, + AWS_REGION_EU_WEST_1, + AWS_REGION_GOV_CLOUD_US_EAST_1, AWS_REGION_US_EAST_1, set_mocked_aws_provider, ) @@ -61,6 +67,44 @@ class TestAWSService: assert service.region == AWS_REGION_US_EAST_1 assert service.client.__class__.__name__ == "CloudFront" + def test_AWSService_global_service_uses_global_region_with_profile_region(self): + """Global services must use the partition's global region, not the profile region.""" + service_name = "cloudfront" + provider = set_mocked_aws_provider(profile_region=AWS_REGION_EU_WEST_1) + service = AWSService(service_name, provider, global_service=True) + + assert service.region == AWS_REGION_US_EAST_1 + + def test_AWSService_non_global_service_uses_profile_region(self): + """Non-global services should use the profile region when available.""" + service_name = "s3" + provider = set_mocked_aws_provider(profile_region=AWS_REGION_EU_WEST_1) + service = AWSService(service_name, provider) + + assert service.region == AWS_REGION_EU_WEST_1 + + def test_AWSService_global_service_china_partition(self): + """Global services in aws-cn partition should use cn-north-1.""" + service_name = "cloudfront" + provider = set_mocked_aws_provider( + audited_partition=AWS_CHINA_PARTITION, + profile_region=AWS_REGION_CN_NORTHWEST_1, + ) + service = AWSService(service_name, provider, global_service=True) + + assert service.region == AWS_REGION_CN_NORTH_1 + + def test_AWSService_global_service_gov_cloud_partition(self): + """Global services in aws-us-gov partition should use us-gov-east-1.""" + service_name = "cloudfront" + provider = set_mocked_aws_provider( + audited_partition=AWS_GOV_CLOUD_PARTITION, + profile_region="us-gov-west-1", + ) + service = AWSService(service_name, provider, global_service=True) + + assert service.region == AWS_REGION_GOV_CLOUD_US_EAST_1 + def test_AWSService_set_failed_check(self): AWSService.failed_checks.clear() diff --git a/tests/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk_test.py b/tests/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk_test.py new file mode 100644 index 0000000000..ac648d2e93 --- /dev/null +++ b/tests/providers/aws/services/awslambda/awslambda_function_env_vars_not_encrypted_with_cmk/awslambda_function_env_vars_not_encrypted_with_cmk_test.py @@ -0,0 +1,197 @@ +from json import dumps +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +ROLE_POLICY = dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } +) + + +class Test_awslambda_function_env_vars_not_encrypted_with_cmk: + def test_no_functions(self): + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + 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.awslambda.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_client", + new=Lambda(aws_provider), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_function_env_vars_not_encrypted_with_cmk import ( + awslambda_function_env_vars_not_encrypted_with_cmk, + ) + + check = awslambda_function_env_vars_not_encrypted_with_cmk() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_function_no_env_vars(self): + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_arn = iam_client.create_role( + RoleName="test-role", + AssumeRolePolicyDocument=ROLE_POLICY, + )["Role"]["Arn"] + + lambda_client = client("lambda", region_name=AWS_REGION_EU_WEST_1) + function_name = "test-fn-no-env" + function_arn = lambda_client.create_function( + FunctionName=function_name, + Runtime="python3.11", + Role=role_arn, + Handler="index.handler", + Code={"ZipFile": b"file not used"}, + )["FunctionArn"] + + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + 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.awslambda.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_client", + new=Lambda(aws_provider), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_function_env_vars_not_encrypted_with_cmk import ( + awslambda_function_env_vars_not_encrypted_with_cmk, + ) + + check = awslambda_function_env_vars_not_encrypted_with_cmk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "no environment variables" in result[0].status_extended + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_function_env_vars_no_kms(self): + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_arn = iam_client.create_role( + RoleName="test-role", + AssumeRolePolicyDocument=ROLE_POLICY, + )["Role"]["Arn"] + + lambda_client = client("lambda", region_name=AWS_REGION_EU_WEST_1) + function_name = "test-fn-env-no-kms" + function_arn = lambda_client.create_function( + FunctionName=function_name, + Runtime="python3.11", + Role=role_arn, + Handler="index.handler", + Code={"ZipFile": b"file not used"}, + Environment={"Variables": {"DB_HOST": "localhost"}}, + )["FunctionArn"] + + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + 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.awslambda.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_client", + new=Lambda(aws_provider), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_function_env_vars_not_encrypted_with_cmk import ( + awslambda_function_env_vars_not_encrypted_with_cmk, + ) + + check = awslambda_function_env_vars_not_encrypted_with_cmk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "customer-managed KMS key" in result[0].status_extended + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_function_env_vars_with_cmk(self): + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_arn = iam_client.create_role( + RoleName="test-role", + AssumeRolePolicyDocument=ROLE_POLICY, + )["Role"]["Arn"] + + lambda_client = client("lambda", region_name=AWS_REGION_EU_WEST_1) + function_name = "test-fn-env-with-kms" + key_arn = ( + f"arn:aws:kms:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:key/test-key-id" + ) + function_arn = lambda_client.create_function( + FunctionName=function_name, + Runtime="python3.11", + Role=role_arn, + Handler="index.handler", + Code={"ZipFile": b"file not used"}, + Environment={"Variables": {"DB_HOST": "localhost"}}, + )["FunctionArn"] + + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + lambda_service = Lambda(aws_provider) + + # moto does not return KMSKeyArn in list_functions; inject it to test PASS branch. + lambda_service.functions[function_arn].kms_key_arn = key_arn + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_client", + new=lambda_service, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_env_vars_not_encrypted_with_cmk.awslambda_function_env_vars_not_encrypted_with_cmk import ( + awslambda_function_env_vars_not_encrypted_with_cmk, + ) + + check = awslambda_function_env_vars_not_encrypted_with_cmk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert key_arn in result[0].status_extended + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue_test.py b/tests/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue_test.py new file mode 100644 index 0000000000..0daeb5261b --- /dev/null +++ b/tests/providers/aws/services/awslambda/awslambda_function_no_dead_letter_queue/awslambda_function_no_dead_letter_queue_test.py @@ -0,0 +1,149 @@ +from json import dumps +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from prowler.providers.aws.services.awslambda.awslambda_service import DeadLetterConfig +from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provider + +ROLE_POLICY = dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } +) + + +class Test_awslambda_function_no_dead_letter_queue: + def test_no_functions(self): + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + 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.awslambda.awslambda_function_no_dead_letter_queue.awslambda_function_no_dead_letter_queue.awslambda_client", + new=Lambda(aws_provider), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_dead_letter_queue.awslambda_function_no_dead_letter_queue import ( + awslambda_function_no_dead_letter_queue, + ) + + check = awslambda_function_no_dead_letter_queue() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_function_without_dlq(self): + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_arn = iam_client.create_role( + RoleName="test-role", + AssumeRolePolicyDocument=ROLE_POLICY, + )["Role"]["Arn"] + + lambda_client = client("lambda", region_name=AWS_REGION_EU_WEST_1) + function_name = "test-function-no-dlq" + function_arn = lambda_client.create_function( + FunctionName=function_name, + Runtime="python3.11", + Role=role_arn, + Handler="index.handler", + Code={"ZipFile": b"file not used"}, + )["FunctionArn"] + + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + 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.awslambda.awslambda_function_no_dead_letter_queue.awslambda_function_no_dead_letter_queue.awslambda_client", + new=Lambda(aws_provider), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_dead_letter_queue.awslambda_function_no_dead_letter_queue import ( + awslambda_function_no_dead_letter_queue, + ) + + check = awslambda_function_no_dead_letter_queue() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert function_name in result[0].status_extended + assert "Dead Letter Queue" in result[0].status_extended + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_function_with_sqs_dlq(self): + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_arn = iam_client.create_role( + RoleName="test-role", + AssumeRolePolicyDocument=ROLE_POLICY, + )["Role"]["Arn"] + + lambda_client = client("lambda", region_name=AWS_REGION_EU_WEST_1) + function_name = "test-function-with-dlq" + queue_arn = f"arn:aws:sqs:{AWS_REGION_EU_WEST_1}:123456789012:test-dlq" + function_arn = lambda_client.create_function( + FunctionName=function_name, + Runtime="python3.11", + Role=role_arn, + Handler="index.handler", + Code={"ZipFile": b"file not used"}, + )["FunctionArn"] + + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + lambda_service = Lambda(aws_provider) + + # moto does not return DeadLetterConfig in list_functions; + # set it directly to test the PASS branch of the check logic. + lambda_service.functions[function_arn].dead_letter_config = DeadLetterConfig( + target_arn=queue_arn + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_no_dead_letter_queue.awslambda_function_no_dead_letter_queue.awslambda_client", + new=lambda_service, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_no_dead_letter_queue.awslambda_function_no_dead_letter_queue import ( + awslambda_function_no_dead_letter_queue, + ) + + check = awslambda_function_no_dead_letter_queue() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert function_name in result[0].status_extended + assert queue_arn in result[0].status_extended + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers_test.py b/tests/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers_test.py new file mode 100644 index 0000000000..3f0090437f --- /dev/null +++ b/tests/providers/aws/services/awslambda/awslambda_function_using_cross_account_layers/awslambda_function_using_cross_account_layers_test.py @@ -0,0 +1,200 @@ +from json import dumps +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +ROLE_POLICY = dumps( + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } +) +EXTERNAL_ACCOUNT = "999999999999" + + +def _create_role(iam_client): + return iam_client.create_role( + RoleName="test-role", + AssumeRolePolicyDocument=ROLE_POLICY, + )["Role"]["Arn"] + + +class Test_awslambda_function_using_cross_account_layers: + def test_no_functions(self): + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + 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.awslambda.awslambda_function_using_cross_account_layers.awslambda_function_using_cross_account_layers.awslambda_client", + new=Lambda(aws_provider), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_using_cross_account_layers.awslambda_function_using_cross_account_layers import ( + awslambda_function_using_cross_account_layers, + ) + + check = awslambda_function_using_cross_account_layers() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_function_no_layers(self): + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_arn = _create_role(iam_client) + + lambda_client = client("lambda", region_name=AWS_REGION_EU_WEST_1) + function_name = "test-fn-no-layers" + function_arn = lambda_client.create_function( + FunctionName=function_name, + Runtime="python3.11", + Role=role_arn, + Handler="index.handler", + Code={"ZipFile": b"file not used"}, + )["FunctionArn"] + + from prowler.providers.aws.services.awslambda.awslambda_service import Lambda + + 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.awslambda.awslambda_function_using_cross_account_layers.awslambda_function_using_cross_account_layers.awslambda_client", + new=Lambda(aws_provider), + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_using_cross_account_layers.awslambda_function_using_cross_account_layers import ( + awslambda_function_using_cross_account_layers, + ) + + check = awslambda_function_using_cross_account_layers() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "does not use any layers" in result[0].status_extended + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + + @mock_aws + def test_function_own_account_layer(self): + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_arn = _create_role(iam_client) + + lambda_client = client("lambda", region_name=AWS_REGION_EU_WEST_1) + function_name = "test-fn-own-layer" + function_arn = lambda_client.create_function( + FunctionName=function_name, + Runtime="python3.11", + Role=role_arn, + Handler="index.handler", + Code={"ZipFile": b"file not used"}, + )["FunctionArn"] + + from prowler.providers.aws.services.awslambda.awslambda_service import ( + Lambda, + Layer, + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + lambda_service = Lambda(aws_provider) + + # moto does not return Layers in list_functions; inject an own-account layer. + own_layer_arn = f"arn:aws:lambda:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:layer:my-layer:1" + lambda_service.functions[function_arn].layers = [Layer(arn=own_layer_arn)] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_using_cross_account_layers.awslambda_function_using_cross_account_layers.awslambda_client", + new=lambda_service, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_using_cross_account_layers.awslambda_function_using_cross_account_layers import ( + awslambda_function_using_cross_account_layers, + ) + + check = awslambda_function_using_cross_account_layers() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert AWS_ACCOUNT_NUMBER in result[0].status_extended + + @mock_aws + def test_function_cross_account_layer(self): + """Function uses a layer from an external account — FAIL.""" + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + role_arn = _create_role(iam_client) + + lambda_client = client("lambda", region_name=AWS_REGION_EU_WEST_1) + function_name = "test-fn-cross-layer" + function_arn = lambda_client.create_function( + FunctionName=function_name, + Runtime="python3.11", + Role=role_arn, + Handler="index.handler", + Code={"ZipFile": b"file not used"}, + )["FunctionArn"] + + from prowler.providers.aws.services.awslambda.awslambda_service import ( + Lambda, + Layer, + ) + + cross_layer_arn = f"arn:aws:lambda:{AWS_REGION_EU_WEST_1}:{EXTERNAL_ACCOUNT}:layer:ext-layer:1" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + lambda_service = Lambda(aws_provider) + + # moto does not return Layers; inject a cross-account layer to test FAIL branch. + lambda_service.functions[function_arn].layers = [Layer(arn=cross_layer_arn)] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.awslambda.awslambda_function_using_cross_account_layers.awslambda_function_using_cross_account_layers.awslambda_client", + new=lambda_service, + ), + ): + from prowler.providers.aws.services.awslambda.awslambda_function_using_cross_account_layers.awslambda_function_using_cross_account_layers import ( + awslambda_function_using_cross_account_layers, + ) + + check = awslambda_function_using_cross_account_layers() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert cross_layer_arn in result[0].status_extended + assert result[0].resource_id == function_name + assert result[0].resource_arn == function_arn + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled_test.py b/tests/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled_test.py index 4a084cf71a..430356f565 100644 --- a/tests/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled_test.py +++ b/tests/providers/aws/services/cloudfront/cloudfront_distributions_logging_enabled/cloudfront_distributions_logging_enabled_test.py @@ -64,7 +64,7 @@ class Test_cloudfront_distributions_logging_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"CloudFront Distribution {DISTRIBUTION_ID} has logging enabled." + == f"CloudFront Distribution {DISTRIBUTION_ID} has logging enabled via standard." ) def test_one_distribution_logging_disabled_realtime_disabled(self): @@ -145,7 +145,7 @@ class Test_cloudfront_distributions_logging_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"CloudFront Distribution {DISTRIBUTION_ID} has logging enabled." + == f"CloudFront Distribution {DISTRIBUTION_ID} has logging enabled via real-time." ) assert result[0].resource_tags == [] @@ -186,6 +186,48 @@ class Test_cloudfront_distributions_logging_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"CloudFront Distribution {DISTRIBUTION_ID} has logging enabled." + == f"CloudFront Distribution {DISTRIBUTION_ID} has logging enabled via standard, real-time." + ) + assert result[0].resource_tags == [] + + def test_one_distribution_logging_v2_enabled(self): + cloudfront_client = mock.MagicMock + cloudfront_client.distributions = { + DISTRIBUTION_ID: Distribution( + arn=DISTRIBUTION_ARN, + id=DISTRIBUTION_ID, + region=REGION, + logging_enabled=False, + logging_v2_enabled=True, + default_cache_config=DefaultCacheConfigBehaviour( + realtime_log_config_arn="", + viewer_protocol_policy=ViewerProtocolPolicy.https_only, + field_level_encryption_id="", + ), + origins=[], + origin_failover=False, + ) + } + + with mock.patch( + "prowler.providers.aws.services.cloudfront.cloudfront_service.CloudFront", + new=cloudfront_client, + ): + # Test Check + from prowler.providers.aws.services.cloudfront.cloudfront_distributions_logging_enabled.cloudfront_distributions_logging_enabled import ( + cloudfront_distributions_logging_enabled, + ) + + check = cloudfront_distributions_logging_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].region == REGION + assert result[0].resource_arn == DISTRIBUTION_ARN + assert result[0].resource_id == DISTRIBUTION_ID + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"CloudFront Distribution {DISTRIBUTION_ID} has logging enabled via v2/CloudWatch." ) assert result[0].resource_tags == [] diff --git a/tests/providers/aws/services/cloudfront/cloudfront_service_test.py b/tests/providers/aws/services/cloudfront/cloudfront_service_test.py index 2af8dead03..4c0a9b349e 100644 --- a/tests/providers/aws/services/cloudfront/cloudfront_service_test.py +++ b/tests/providers/aws/services/cloudfront/cloudfront_service_test.py @@ -171,6 +171,10 @@ def mock_make_api_call(self, operation_name, kwarg): ] } } + if operation_name == "DescribeDeliverySources": + return {"deliverySources": []} + if operation_name == "DescribeDeliveries": + return {"deliveries": []} return make_api_call(self, operation_name, kwarg) @@ -284,3 +288,158 @@ class Test_CloudFront_Service: == DEFAULT_CACHE_CONFIG.field_level_encryption_id ) assert cloudfront.distributions[DISTRIBUTION_ID].tags == TAGS + + def test_get_log_delivery_sources_with_active_delivery(self): + from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER + + DISTRIBUTION_ID = "E27LVI50CSW06W" + DISTRIBUTION_ARN = ( + f"arn:aws:cloudfront::{AWS_ACCOUNT_NUMBER}:distribution/{DISTRIBUTION_ID}" + ) + REGION = "us-east-1" + + distributions = { + DISTRIBUTION_ID: Distribution( + arn=DISTRIBUTION_ARN, + id=DISTRIBUTION_ID, + region=REGION, + origins=[], + origin_failover=False, + ) + } + + mock_logs_client = mock.MagicMock() + mock_paginator_sources = mock.MagicMock() + mock_paginator_sources.paginate.return_value = [ + { + "deliverySources": [ + { + "name": "cf-source-1", + "service": "cloudfront", + "resourceArns": [DISTRIBUTION_ARN], + }, + ] + } + ] + mock_paginator_deliveries = mock.MagicMock() + mock_paginator_deliveries.paginate.return_value = [ + { + "deliveries": [ + { + "id": "delivery-1", + "deliverySourceName": "cf-source-1", + "deliveryDestinationArn": "arn:aws:logs:us-east-1:123456789012:log-group:cf-logs", + }, + ] + } + ] + + def paginator_side_effect(operation): + if operation == "describe_delivery_sources": + return mock_paginator_sources + if operation == "describe_deliveries": + return mock_paginator_deliveries + + mock_logs_client.get_paginator.side_effect = paginator_side_effect + + service = mock.MagicMock() + service.service = "cloudfront" + service.session.client.return_value = mock_logs_client + + CloudFront._get_log_delivery_sources(service, distributions, REGION) + + assert distributions[DISTRIBUTION_ID].logging_v2_enabled is True + + def test_get_log_delivery_sources_source_without_delivery(self): + from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER + + DISTRIBUTION_ID = "E27LVI50CSW06W" + DISTRIBUTION_ARN = ( + f"arn:aws:cloudfront::{AWS_ACCOUNT_NUMBER}:distribution/{DISTRIBUTION_ID}" + ) + REGION = "us-east-1" + + distributions = { + DISTRIBUTION_ID: Distribution( + arn=DISTRIBUTION_ARN, + id=DISTRIBUTION_ID, + region=REGION, + origins=[], + origin_failover=False, + ) + } + + mock_logs_client = mock.MagicMock() + mock_paginator_sources = mock.MagicMock() + mock_paginator_sources.paginate.return_value = [ + { + "deliverySources": [ + { + "name": "cf-source-1", + "service": "cloudfront", + "resourceArns": [DISTRIBUTION_ARN], + }, + ] + } + ] + mock_paginator_deliveries = mock.MagicMock() + mock_paginator_deliveries.paginate.return_value = [{"deliveries": []}] + + def paginator_side_effect(operation): + if operation == "describe_delivery_sources": + return mock_paginator_sources + if operation == "describe_deliveries": + return mock_paginator_deliveries + + mock_logs_client.get_paginator.side_effect = paginator_side_effect + + service = mock.MagicMock() + service.service = "cloudfront" + service.session.client.return_value = mock_logs_client + + CloudFront._get_log_delivery_sources(service, distributions, REGION) + + assert distributions[DISTRIBUTION_ID].logging_v2_enabled is False + + def test_get_log_delivery_sources_no_matching_sources(self): + from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER + + DISTRIBUTION_ID = "E27LVI50CSW06W" + DISTRIBUTION_ARN = ( + f"arn:aws:cloudfront::{AWS_ACCOUNT_NUMBER}:distribution/{DISTRIBUTION_ID}" + ) + REGION = "us-east-1" + + distributions = { + DISTRIBUTION_ID: Distribution( + arn=DISTRIBUTION_ARN, + id=DISTRIBUTION_ID, + region=REGION, + origins=[], + origin_failover=False, + ) + } + + mock_logs_client = mock.MagicMock() + mock_paginator_sources = mock.MagicMock() + mock_paginator_sources.paginate.return_value = [ + { + "deliverySources": [ + { + "name": "other-source", + "service": "VPC", + "resourceArns": ["arn:aws:some:other:resource"], + }, + ] + } + ] + + mock_logs_client.get_paginator.return_value = mock_paginator_sources + + service = mock.MagicMock() + service.service = "cloudfront" + service.session.client.return_value = mock_logs_client + + CloudFront._get_log_delivery_sources(service, distributions, REGION) + + assert distributions[DISTRIBUTION_ID].logging_v2_enabled is False diff --git a/tests/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/__init__.py b/tests/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip_test.py b/tests/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip_test.py new file mode 100644 index 0000000000..870f95bc38 --- /dev/null +++ b/tests/providers/aws/services/ec2/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip/ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip_test.py @@ -0,0 +1,521 @@ +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, +) + +CHECK_MODULE = "prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip" + + +class Test_ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip: + @mock_aws + def test_ec2_default_sgs(self): + """Default SGs with no custom rules should PASS.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + 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( + f"{CHECK_MODULE}.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip import ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip, + ) + + check = ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip() + result = check.execute() + + # One default sg per region (2 regions) + 1 extra from create_vpc + assert len(result) == 3 + assert all(sg.status == "PASS" for sg in result) + + @mock_aws + def test_sg_with_specific_public_ip_ingress(self): + """SG with a specific public IP (not 0.0.0.0/0) open to all protocols should FAIL.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_response = ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + vpc_id = vpc_response["Vpc"]["VpcId"] + + subnet_response = ec2_client.create_subnet( + VpcId=vpc_id, CidrBlock="10.0.1.0/24" + ) + subnet_id = subnet_response["Subnet"]["SubnetId"] + + default_sg = ec2_client.describe_security_groups(GroupNames=["default"])[ + "SecurityGroups" + ][0] + default_sg_id = default_sg["GroupId"] + default_sg_name = default_sg["GroupName"] + + # Add a specific public IP ingress rule (all protocols) + ec2_client.authorize_security_group_ingress( + GroupId=default_sg_id, + IpPermissions=[ + { + "IpProtocol": "-1", + "IpRanges": [{"CidrIp": "52.94.76.5/32"}], + } + ], + ) + + # Create Network Interface to make the SG in-use + ec2_client.create_network_interface( + SubnetId=subnet_id, + Groups=[default_sg_id], + Description="Test ENI", + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + 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( + f"{CHECK_MODULE}.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip import ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip, + ) + + check = ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip() + result = check.execute() + + for sg in result: + if sg.resource_id == default_sg_id: + assert sg.status == "FAIL" + assert sg.region == AWS_REGION_US_EAST_1 + assert ( + sg.status_extended + == f"Security group {default_sg_name} ({default_sg_id}) has a port open to a specific public IP address in ingress rule." + ) + assert sg.resource_details == default_sg_name + + @mock_aws + def test_sg_with_private_ip_ingress(self): + """SG with a private (RFC1918) IP ingress should PASS.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + default_sg = ec2_client.describe_security_groups(GroupNames=["default"])[ + "SecurityGroups" + ][0] + default_sg_id = default_sg["GroupId"] + + # Add a private IP ingress rule + ec2_client.authorize_security_group_ingress( + GroupId=default_sg_id, + IpPermissions=[ + { + "IpProtocol": "-1", + "IpRanges": [{"CidrIp": "10.0.0.0/8"}], + } + ], + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + 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( + f"{CHECK_MODULE}.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip import ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip, + ) + + check = ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip() + result = check.execute() + + assert len(result) == 3 + for sg in result: + if sg.resource_id == default_sg_id: + assert sg.status == "PASS" + + @mock_aws + def test_sg_with_specific_port_public_ip(self): + """SG with a specific public IP on a specific port (not all protocols) should FAIL.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_response = ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + vpc_id = vpc_response["Vpc"]["VpcId"] + + subnet_response = ec2_client.create_subnet( + VpcId=vpc_id, CidrBlock="10.0.1.0/24" + ) + subnet_id = subnet_response["Subnet"]["SubnetId"] + + default_sg = ec2_client.describe_security_groups(GroupNames=["default"])[ + "SecurityGroups" + ][0] + default_sg_id = default_sg["GroupId"] + default_sg_name = default_sg["GroupName"] + + # Add a specific public IP on a specific port + ec2_client.authorize_security_group_ingress( + GroupId=default_sg_id, + IpPermissions=[ + { + "FromPort": 8080, + "IpProtocol": "tcp", + "IpRanges": [{"CidrIp": "52.94.76.10/32"}], + "Ipv6Ranges": [], + "ToPort": 8080, + } + ], + ) + + # Create Network Interface + ec2_client.create_network_interface( + SubnetId=subnet_id, + Groups=[default_sg_id], + Description="Test ENI", + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + 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( + f"{CHECK_MODULE}.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip import ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip, + ) + + check = ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip() + result = check.execute() + + for sg in result: + if sg.resource_id == default_sg_id: + assert sg.status == "FAIL" + assert ( + sg.status_extended + == f"Security group {default_sg_name} ({default_sg_id}) has a port open to a specific public IP address in ingress rule." + ) + + @mock_aws + def test_sg_pass_when_all_ports_already_failed(self): + """SG already flagged by the all_ports check should PASS with explanation.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + default_sg = ec2_client.describe_security_groups(GroupNames=["default"])[ + "SecurityGroups" + ][0] + default_sg_id = default_sg["GroupId"] + default_sg_name = default_sg["GroupName"] + + # Add 0.0.0.0/0 all protocols (triggers all_ports check) + ec2_client.authorize_security_group_ingress( + GroupId=default_sg_id, + IpPermissions=[ + { + "IpProtocol": "-1", + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + } + ], + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + 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_securitygroup_allow_ingress_from_internet_to_all_ports.ec2_securitygroup_allow_ingress_from_internet_to_all_ports.ec2_client", + new=EC2(aws_provider), + ) as ec2_mock, + mock.patch( + "prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_all_ports.ec2_securitygroup_allow_ingress_from_internet_to_all_ports.vpc_client", + new=VPC(aws_provider), + ), + ): + # Run all_ports check first to set the failed flag + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_all_ports.ec2_securitygroup_allow_ingress_from_internet_to_all_ports import ( + ec2_securitygroup_allow_ingress_from_internet_to_all_ports, + ) + + check_all = ec2_securitygroup_allow_ingress_from_internet_to_all_ports() + result_all = check_all.execute() + + # Verify the all_ports check flagged it + assert any( + sg.status == "FAIL" and sg.resource_id == default_sg_id + for sg in result_all + ) + + # Now run our check with the same ec2_client (which has the failed flags) + with ( + mock.patch( + f"{CHECK_MODULE}.ec2_client", + new=ec2_mock, + ), + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip import ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip, + ) + + check = ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip() + ) + result = check.execute() + + # The SG with 0.0.0.0/0 should PASS with explanation + for sg in result: + if sg.resource_id == default_sg_id: + assert sg.status == "PASS" + assert ( + sg.status_extended + == f"Security group {default_sg_name} ({default_sg_id}) has all ports open to the Internet and therefore was not checked against specific public IP ingress rules." + ) + + @mock_aws + def test_ec2_default_sgs_ignoring_unused(self): + """Unused SGs should be skipped when scan_unused_services is False.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1], + scan_unused_services=False, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + f"{CHECK_MODULE}.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip import ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip, + ) + + check = ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_sg_with_wildcard_cidr_on_specific_port(self): + """SG with 0.0.0.0/0 on a specific port should PASS (covered by other checks).""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_response = ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + vpc_id = vpc_response["Vpc"]["VpcId"] + + subnet_response = ec2_client.create_subnet( + VpcId=vpc_id, CidrBlock="10.0.1.0/24" + ) + subnet_id = subnet_response["Subnet"]["SubnetId"] + + default_sg = ec2_client.describe_security_groups(GroupNames=["default"])[ + "SecurityGroups" + ][0] + default_sg_id = default_sg["GroupId"] + + # Add 0.0.0.0/0 on a specific port + ec2_client.authorize_security_group_ingress( + GroupId=default_sg_id, + IpPermissions=[ + { + "FromPort": 443, + "IpProtocol": "tcp", + "IpRanges": [{"CidrIp": "0.0.0.0/0"}], + "Ipv6Ranges": [], + "ToPort": 443, + } + ], + ) + + # Create Network Interface + ec2_client.create_network_interface( + SubnetId=subnet_id, + Groups=[default_sg_id], + Description="Test ENI", + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + 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( + f"{CHECK_MODULE}.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip import ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip, + ) + + check = ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip() + result = check.execute() + + for sg in result: + if sg.resource_id == default_sg_id: + assert sg.status == "PASS" + + @mock_aws + def test_sg_with_ipv6_public_address(self): + """SG with a specific public IPv6 address should FAIL.""" + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + vpc_response = ec2_client.create_vpc(CidrBlock="10.0.0.0/16") + vpc_id = vpc_response["Vpc"]["VpcId"] + + subnet_response = ec2_client.create_subnet( + VpcId=vpc_id, CidrBlock="10.0.1.0/24" + ) + subnet_id = subnet_response["Subnet"]["SubnetId"] + + default_sg = ec2_client.describe_security_groups(GroupNames=["default"])[ + "SecurityGroups" + ][0] + default_sg_id = default_sg["GroupId"] + default_sg_name = default_sg["GroupName"] + + # Add a public IPv6 ingress rule + ec2_client.authorize_security_group_ingress( + GroupId=default_sg_id, + IpPermissions=[ + { + "IpProtocol": "-1", + "IpRanges": [], + "Ipv6Ranges": [{"CidrIpv6": "2600:1f18::/32"}], + } + ], + ) + + # Create Network Interface + ec2_client.create_network_interface( + SubnetId=subnet_id, + Groups=[default_sg_id], + Description="Test ENI", + ) + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.vpc.vpc_service import VPC + + 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( + f"{CHECK_MODULE}.ec2_client", + new=EC2(aws_provider), + ), + mock.patch( + f"{CHECK_MODULE}.vpc_client", + new=VPC(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip.ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip import ( + ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip, + ) + + check = ec2_securitygroup_allow_ingress_from_internet_to_any_port_from_ip() + result = check.execute() + + for sg in result: + if sg.resource_id == default_sg_id: + assert sg.status == "FAIL" + assert ( + sg.status_extended + == f"Security group {default_sg_name} ({default_sg_id}) has a port open to a specific public IP address in ingress rule." + ) diff --git a/tests/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments_test.py b/tests/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments_test.py new file mode 100644 index 0000000000..def9b16a13 --- /dev/null +++ b/tests/providers/aws/services/glue/glue_etl_jobs_no_secrets_in_arguments/glue_etl_jobs_no_secrets_in_arguments_test.py @@ -0,0 +1,190 @@ +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + + +class Test_glue_etl_jobs_no_secrets_in_arguments: + @mock_aws + def test_glue_no_jobs(self): + from prowler.providers.aws.services.glue.glue_service import Glue + + 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.glue.glue_etl_jobs_no_secrets_in_arguments.glue_etl_jobs_no_secrets_in_arguments.glue_client", + new=Glue(aws_provider), + ): + from prowler.providers.aws.services.glue.glue_etl_jobs_no_secrets_in_arguments.glue_etl_jobs_no_secrets_in_arguments import ( + glue_etl_jobs_no_secrets_in_arguments, + ) + + check = glue_etl_jobs_no_secrets_in_arguments() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_glue_job_no_secrets(self): + glue_client = client("glue", region_name=AWS_REGION_US_EAST_1) + job_name = "test-job" + job_arn = ( + f"arn:aws:glue:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:job/{job_name}" + ) + glue_client.create_job( + Name=job_name, + Role="role_test", + Command={"Name": "name_test", "ScriptLocation": "script_test"}, + DefaultArguments={ + "--enable-continuous-cloudwatch-log": "true", + "--TempDir": "s3://my-bucket/temp/", + }, + Tags={"key_test": "value_test"}, + GlueVersion="1.0", + MaxCapacity=0.0625, + MaxRetries=0, + Timeout=10, + NumberOfWorkers=2, + WorkerType="G.1X", + ) + + from prowler.providers.aws.services.glue.glue_service import Glue + + 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.glue.glue_etl_jobs_no_secrets_in_arguments.glue_etl_jobs_no_secrets_in_arguments.glue_client", + new=Glue(aws_provider), + ): + from prowler.providers.aws.services.glue.glue_etl_jobs_no_secrets_in_arguments.glue_etl_jobs_no_secrets_in_arguments import ( + glue_etl_jobs_no_secrets_in_arguments, + ) + + check = glue_etl_jobs_no_secrets_in_arguments() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"No secrets found in Glue job {job_name} default arguments." + ) + assert result[0].resource_id == job_name + assert result[0].resource_arn == job_arn + assert result[0].resource_tags == [{"key_test": "value_test"}] + + @mock_aws + def test_glue_job_with_secrets(self): + glue_client = client("glue", region_name=AWS_REGION_US_EAST_1) + job_name = "test-job" + job_arn = ( + f"arn:aws:glue:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:job/{job_name}" + ) + glue_client.create_job( + Name=job_name, + Role="role_test", + Command={"Name": "name_test", "ScriptLocation": "script_test"}, + DefaultArguments={ + "--db-password": "AKIAsupersecretkey1234", + "--TempDir": "s3://my-bucket/temp/", + }, + Tags={"key_test": "value_test"}, + GlueVersion="1.0", + MaxCapacity=0.0625, + MaxRetries=0, + Timeout=10, + NumberOfWorkers=2, + WorkerType="G.1X", + ) + + from prowler.providers.aws.services.glue.glue_service import Glue + + 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.glue.glue_etl_jobs_no_secrets_in_arguments.glue_etl_jobs_no_secrets_in_arguments.glue_client", + new=Glue(aws_provider), + ): + from prowler.providers.aws.services.glue.glue_etl_jobs_no_secrets_in_arguments.glue_etl_jobs_no_secrets_in_arguments import ( + glue_etl_jobs_no_secrets_in_arguments, + ) + + check = glue_etl_jobs_no_secrets_in_arguments() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "Potential secrets found" in result[0].status_extended + assert job_name in result[0].status_extended + assert "--db-password" in result[0].status_extended + assert result[0].resource_id == job_name + assert result[0].resource_arn == job_arn + assert result[0].resource_tags == [{"key_test": "value_test"}] + + @mock_aws + def test_glue_job_empty_arguments(self): + glue_client = client("glue", region_name=AWS_REGION_US_EAST_1) + job_name = "test-job" + job_arn = ( + f"arn:aws:glue:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:job/{job_name}" + ) + glue_client.create_job( + Name=job_name, + Role="role_test", + Command={"Name": "name_test", "ScriptLocation": "script_test"}, + DefaultArguments={}, + Tags={"key_test": "value_test"}, + GlueVersion="1.0", + MaxCapacity=0.0625, + MaxRetries=0, + Timeout=10, + NumberOfWorkers=2, + WorkerType="G.1X", + ) + + from prowler.providers.aws.services.glue.glue_service import Glue + + 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.glue.glue_etl_jobs_no_secrets_in_arguments.glue_etl_jobs_no_secrets_in_arguments.glue_client", + new=Glue(aws_provider), + ): + from prowler.providers.aws.services.glue.glue_etl_jobs_no_secrets_in_arguments.glue_etl_jobs_no_secrets_in_arguments import ( + glue_etl_jobs_no_secrets_in_arguments, + ) + + check = glue_etl_jobs_no_secrets_in_arguments() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"No secrets found in Glue job {job_name} default arguments." + ) + assert result[0].resource_id == job_name + assert result[0].resource_arn == job_arn + assert result[0].resource_tags == [{"key_test": "value_test"}] diff --git a/tests/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions_test.py b/tests/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions_test.py new file mode 100644 index 0000000000..d171aa2473 --- /dev/null +++ b/tests/providers/aws/services/guardduty/guardduty_delegated_admin_enabled_all_regions/guardduty_delegated_admin_enabled_all_regions_test.py @@ -0,0 +1,233 @@ +from unittest.mock import patch + +import botocore +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call_org_admin_and_config(self, operation_name, api_params): + """Mock organization admin accounts and configuration APIs.""" + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnableOrganizationMembers": "ALL", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_org_admin_no_auto_enable(self, operation_name, api_params): + """Mock organization admin configured but auto-enable disabled.""" + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnableOrganizationMembers": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_no_org_admin(self, operation_name, api_params): + """Mock no organization admin configured.""" + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnableOrganizationMembers": "NONE", + } + return orig(self, operation_name, api_params) + + +class Test_guardduty_delegated_admin_enabled_all_regions: + @mock_aws + def test_no_detectors(self): + """Test when no GuardDuty detectors exist.""" + aws_provider = set_mocked_aws_provider() + + from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.guardduty.guardduty_delegated_admin_enabled_all_regions.guardduty_delegated_admin_enabled_all_regions.guardduty_client", + new=GuardDuty(aws_provider), + ), + ): + from prowler.providers.aws.services.guardduty.guardduty_delegated_admin_enabled_all_regions.guardduty_delegated_admin_enabled_all_regions import ( + guardduty_delegated_admin_enabled_all_regions, + ) + + check = guardduty_delegated_admin_enabled_all_regions() + result = check.execute() + + # Should have findings for each region (with unknown detectors) + assert len(result) > 0 + # All should fail since no detectors are enabled + for finding in result: + assert finding.status == "FAIL" + assert "detector not enabled" in finding.status_extended + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_org_admin, + ) + @mock_aws + def test_detector_enabled_no_delegated_admin(self): + """Test when detector is enabled but no delegated admin is configured.""" + guardduty_client_boto = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + detector_id = guardduty_client_boto.create_detector(Enable=True)["DetectorId"] + + aws_provider = set_mocked_aws_provider() + + from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.guardduty.guardduty_delegated_admin_enabled_all_regions.guardduty_delegated_admin_enabled_all_regions.guardduty_client", + new=GuardDuty(aws_provider), + ), + ): + from prowler.providers.aws.services.guardduty.guardduty_delegated_admin_enabled_all_regions.guardduty_delegated_admin_enabled_all_regions import ( + guardduty_delegated_admin_enabled_all_regions, + ) + + check = guardduty_delegated_admin_enabled_all_regions() + result = check.execute() + + # Find the result for our region + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "no delegated administrator configured" + in eu_west_1_result.status_extended + ) + assert eu_west_1_result.resource_id == detector_id + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_no_auto_enable, + ) + @mock_aws + def test_detector_enabled_with_admin_no_auto_enable(self): + """Test when detector is enabled with delegated admin but auto-enable is off.""" + guardduty_client_boto = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + detector_id = guardduty_client_boto.create_detector(Enable=True)["DetectorId"] + + aws_provider = set_mocked_aws_provider() + + from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.guardduty.guardduty_delegated_admin_enabled_all_regions.guardduty_delegated_admin_enabled_all_regions.guardduty_client", + new=GuardDuty(aws_provider), + ), + ): + from prowler.providers.aws.services.guardduty.guardduty_delegated_admin_enabled_all_regions.guardduty_delegated_admin_enabled_all_regions import ( + guardduty_delegated_admin_enabled_all_regions, + ) + + check = guardduty_delegated_admin_enabled_all_regions() + result = check.execute() + + # Find the result for our region + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "organization auto-enable not configured" + in eu_west_1_result.status_extended + ) + assert eu_west_1_result.resource_id == detector_id + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_and_config, + ) + @mock_aws + def test_detector_enabled_with_admin_and_auto_enable(self): + """Test when detector is enabled with delegated admin and auto-enable is on (PASS).""" + guardduty_client_boto = client("guardduty", region_name=AWS_REGION_EU_WEST_1) + detector_id = guardduty_client_boto.create_detector(Enable=True)["DetectorId"] + + aws_provider = set_mocked_aws_provider() + + from prowler.providers.aws.services.guardduty.guardduty_service import GuardDuty + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.guardduty.guardduty_delegated_admin_enabled_all_regions.guardduty_delegated_admin_enabled_all_regions.guardduty_client", + new=GuardDuty(aws_provider), + ), + ): + from prowler.providers.aws.services.guardduty.guardduty_delegated_admin_enabled_all_regions.guardduty_delegated_admin_enabled_all_regions import ( + guardduty_delegated_admin_enabled_all_regions, + ) + + check = guardduty_delegated_admin_enabled_all_regions() + result = check.execute() + + # Find the result for our region + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert "delegated admin configured" in eu_west_1_result.status_extended + assert "auto-enable active" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_id == detector_id + assert ( + eu_west_1_result.resource_arn + == f"arn:aws:guardduty:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:detector/{detector_id}" + ) diff --git a/tests/providers/aws/services/iam/lib/policy_test.py b/tests/providers/aws/services/iam/lib/policy_test.py index fe25b4ca0f..afea8ca658 100644 --- a/tests/providers/aws/services/iam/lib/policy_test.py +++ b/tests/providers/aws/services/iam/lib/policy_test.py @@ -13,6 +13,7 @@ from prowler.providers.aws.services.iam.lib.policy import ( is_condition_block_restrictive_organization, is_condition_block_restrictive_sns_endpoint, is_condition_restricting_from_private_ip, + is_condition_restricting_to_trusted_ips, is_policy_public, ) @@ -1982,6 +1983,49 @@ class Test_Policy: } assert not is_condition_restricting_from_private_ip(condition_from_invalid_ip) + def test_is_condition_restricting_to_trusted_ips_no_trusted_ips(self): + condition = {"IpAddress": {"aws:SourceIp": "1.2.3.4"}} + assert not is_condition_restricting_to_trusted_ips(condition) + + def test_is_condition_restricting_to_trusted_ips_empty_trusted_ips(self): + condition = {"IpAddress": {"aws:SourceIp": "1.2.3.4"}} + assert not is_condition_restricting_to_trusted_ips(condition, []) + + def test_is_condition_restricting_to_trusted_ips_matching(self): + condition = {"IpAddress": {"aws:SourceIp": "1.2.3.4"}} + assert is_condition_restricting_to_trusted_ips(condition, ["1.2.3.4"]) + + def test_is_condition_restricting_to_trusted_ips_not_matching(self): + condition = {"IpAddress": {"aws:SourceIp": "5.6.7.8"}} + assert not is_condition_restricting_to_trusted_ips(condition, ["1.2.3.4"]) + + def test_is_condition_restricting_to_trusted_ips_wildcard(self): + condition = {"IpAddress": {"aws:SourceIp": "*"}} + assert not is_condition_restricting_to_trusted_ips(condition, ["1.2.3.4"]) + + def test_is_condition_restricting_to_trusted_ips_open_cidr(self): + condition = {"IpAddress": {"aws:SourceIp": "0.0.0.0/0"}} + assert not is_condition_restricting_to_trusted_ips(condition, ["1.2.3.4"]) + + def test_is_condition_restricting_to_trusted_ips_multiple_ips_all_trusted(self): + condition = {"IpAddress": {"aws:SourceIp": ["1.2.3.4", "5.6.7.8"]}} + assert is_condition_restricting_to_trusted_ips( + condition, ["1.2.3.4", "5.6.7.8"] + ) + + def test_is_condition_restricting_to_trusted_ips_multiple_ips_partial_trusted(self): + condition = {"IpAddress": {"aws:SourceIp": ["1.2.3.4", "9.9.9.9"]}} + assert not is_condition_restricting_to_trusted_ips( + condition, ["1.2.3.4", "5.6.7.8"] + ) + + def test_is_condition_restricting_to_trusted_ips_cidr_range(self): + condition = {"IpAddress": {"aws:SourceIp": "10.0.0.0/8"}} + assert is_condition_restricting_to_trusted_ips(condition, ["10.0.0.0/8"]) + + def test_is_condition_restricting_to_trusted_ips_no_condition(self): + assert not is_condition_restricting_to_trusted_ips({}, ["1.2.3.4"]) + def test_is_policy_public_(self): policy = { "Statement": [ @@ -2271,6 +2315,48 @@ class Test_Policy: } assert is_policy_public(policy, TRUSTED_AWS_ACCOUNT_NUMBER) + def test_is_policy_public_with_trusted_ips(self): + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["*"], + "Condition": { + "IpAddress": {"aws:SourceIp": ["1.2.3.4", "5.6.7.8"]} + }, + "Resource": "*", + } + ], + } + assert not is_policy_public( + policy, + TRUSTED_AWS_ACCOUNT_NUMBER, + trusted_ips=["1.2.3.4", "5.6.7.8"], + ) + + def test_is_policy_public_with_trusted_ips_partial_match(self): + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["*"], + "Condition": { + "IpAddress": {"aws:SourceIp": ["1.2.3.4", "9.9.9.9"]} + }, + "Resource": "*", + } + ], + } + assert is_policy_public( + policy, + TRUSTED_AWS_ACCOUNT_NUMBER, + trusted_ips=["1.2.3.4", "5.6.7.8"], + ) + def test_check_admin_access(self): policy = { "Version": "2012-10-17", diff --git a/tests/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible_test.py b/tests/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible_test.py index cdbb87c1a8..8003e9a1b7 100644 --- a/tests/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible_test.py +++ b/tests/providers/aws/services/opensearch/opensearch_service_domains_not_publicly_accessible/opensearch_service_domains_not_publicly_accessible_test.py @@ -74,6 +74,19 @@ policy_data_source_whole_internet = { ], } +policy_data_trusted_ip = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS": "*"}, + "Action": ["es:ESHttp*"], + "Condition": {"IpAddress": {"aws:SourceIp": ["1.2.3.4", "5.6.7.8"]}}, + "Resource": f"arn:aws:es:us-west-2:{AWS_ACCOUNT_NUMBER}:domain/{domain_name}/*", + } + ], +} + class Test_opensearch_service_domains_not_publicly_accessible: @mock_aws @@ -304,3 +317,87 @@ class Test_opensearch_service_domains_not_publicly_accessible: assert result[0].resource_arn == domain_arn assert result[0].region == AWS_REGION_US_WEST_2 assert result[0].resource_tags == [] + + @mock_aws + def test_policy_data_not_restricted_with_trusted_ips(self): + opensearch_client = client("opensearch", region_name=AWS_REGION_US_WEST_2) + domain_arn = opensearch_client.create_domain( + DomainName=domain_name, + AccessPolicies=dumps(policy_data_trusted_ip), + )["DomainStatus"]["ARN"] + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2]) + aws_provider._audit_config = {"trusted_ips": ["1.2.3.4", "5.6.7.8"]} + + from prowler.providers.aws.services.opensearch.opensearch_service import ( + OpenSearchService, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.opensearch.opensearch_service_domains_not_publicly_accessible.opensearch_service_domains_not_publicly_accessible.opensearch_client", + new=OpenSearchService(aws_provider), + ), + ): + from prowler.providers.aws.services.opensearch.opensearch_service_domains_not_publicly_accessible.opensearch_service_domains_not_publicly_accessible import ( + opensearch_service_domains_not_publicly_accessible, + ) + + check = opensearch_service_domains_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Opensearch domain {domain_name} is not publicly accessible." + ) + assert result[0].resource_id == domain_name + assert result[0].resource_arn == domain_arn + assert result[0].region == AWS_REGION_US_WEST_2 + assert result[0].resource_tags == [] + + @mock_aws + def test_policy_data_not_restricted_with_trusted_ips_partial_match(self): + opensearch_client = client("opensearch", region_name=AWS_REGION_US_WEST_2) + domain_arn = opensearch_client.create_domain( + DomainName=domain_name, + AccessPolicies=dumps(policy_data_trusted_ip), + )["DomainStatus"]["ARN"] + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_WEST_2]) + aws_provider._audit_config = {"trusted_ips": ["1.2.3.4"]} + + from prowler.providers.aws.services.opensearch.opensearch_service import ( + OpenSearchService, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.opensearch.opensearch_service_domains_not_publicly_accessible.opensearch_service_domains_not_publicly_accessible.opensearch_client", + new=OpenSearchService(aws_provider), + ), + ): + from prowler.providers.aws.services.opensearch.opensearch_service_domains_not_publicly_accessible.opensearch_service_domains_not_publicly_accessible import ( + opensearch_service_domains_not_publicly_accessible, + ) + + check = opensearch_service_domains_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Opensearch domain {domain_name} is publicly accessible via access policy." + ) + assert result[0].resource_id == domain_name + assert result[0].resource_arn == domain_arn + assert result[0].region == AWS_REGION_US_WEST_2 + assert result[0].resource_tags == [] diff --git a/tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py b/tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py index 8d05e64a6e..63cc9193d9 100644 --- a/tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py +++ b/tests/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover_test.py @@ -3,7 +3,11 @@ from unittest import mock from boto3 import client, resource from moto import mock_aws -from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider +from tests.providers.aws.utils import ( + AWS_REGION_US_EAST_1, + AWS_REGION_US_WEST_2, + set_mocked_aws_provider, +) HOSTED_ZONE_NAME = "testdns.aws.com." @@ -309,7 +313,10 @@ class Test_route53_dangling_ip_subdomain_takeover: from prowler.providers.aws.services.ec2.ec2_service import EC2 from prowler.providers.aws.services.route53.route53_service import Route53 - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + expected_checks=["route53_dangling_ip_subdomain_takeover"], + ) with mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -387,7 +394,10 @@ class Test_route53_dangling_ip_subdomain_takeover: from prowler.providers.aws.services.ec2.ec2_service import EC2 from prowler.providers.aws.services.route53.route53_service import Route53 - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + expected_checks=["route53_dangling_ip_subdomain_takeover"], + ) with mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -426,3 +436,69 @@ class Test_route53_dangling_ip_subdomain_takeover: result[0].resource_arn == f"arn:{aws_provider.identity.partition}:route53:::hostedzone/{zone_id.replace('/hostedzone/', '')}" ) + + @mock_aws + def test_hosted_zone_eip_cross_region(self): + """EIP in us-west-2 referenced by Route53 A record should PASS even when auditing us-east-1 only.""" + conn = client("route53", region_name=AWS_REGION_US_EAST_1) + ec2_west = client("ec2", region_name=AWS_REGION_US_WEST_2) + + address = "17.5.7.3" + ec2_west.allocate_address(Domain="vpc", Address=address) + + zone_id = conn.create_hosted_zone( + Name=HOSTED_ZONE_NAME, CallerReference=str(hash("foo")) + )["HostedZone"]["Id"] + + record_set_name = "foo.bar.testdns.aws.com." + record_ip = address + conn.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Changes": [ + { + "Action": "CREATE", + "ResourceRecordSet": { + "Name": record_set_name, + "Type": "A", + "ResourceRecords": [{"Value": record_ip}], + }, + } + ] + }, + ) + from prowler.providers.aws.services.ec2.ec2_service import EC2 + from prowler.providers.aws.services.route53.route53_service import Route53 + + # Audit only us-east-1 but enable both regions so Route53 finds the cross-region EIP + aws_provider = set_mocked_aws_provider( + audited_regions=[AWS_REGION_US_EAST_1], + enabled_regions={AWS_REGION_US_EAST_1, AWS_REGION_US_WEST_2}, + expected_checks=["route53_dangling_ip_subdomain_takeover"], + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.route53_client", + new=Route53(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover.ec2_client", + new=EC2(aws_provider), + ): + from prowler.providers.aws.services.route53.route53_dangling_ip_subdomain_takeover.route53_dangling_ip_subdomain_takeover import ( + route53_dangling_ip_subdomain_takeover, + ) + + check = route53_dangling_ip_subdomain_takeover() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Route53 record {record_ip} (name: {record_set_name}) in Hosted Zone {HOSTED_ZONE_NAME} is not a dangling IP." + ) 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 new file mode 100644 index 0000000000..3909b80568 --- /dev/null +++ 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 @@ -0,0 +1,280 @@ +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + + +class Test_entra_conditional_access_policy_require_mfa_for_admin_portals: + def test_entra_no_subscriptions(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals import ( + entra_conditional_access_policy_require_mfa_for_admin_portals, + ) + + entra_client.conditional_access_policy = {} + + check = entra_conditional_access_policy_require_mfa_for_admin_portals() + result = check.execute() + assert len(result) == 0 + + def test_entra_tenant_no_policies(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals import ( + entra_conditional_access_policy_require_mfa_for_admin_portals, + ) + + entra_client.conditional_access_policy = {DOMAIN: {}} + + check = entra_conditional_access_policy_require_mfa_for_admin_portals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_name == "Conditional Access Policy" + assert result[0].resource_id == "Conditional Access Policy" + assert ( + result[0].status_extended + == "Conditional Access Policy does not require MFA for Microsoft Admin Portals." + ) + + def test_entra_tenant_policy_no_mfa(self): + entra_client = mock.MagicMock + policy_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals import ( + entra_conditional_access_policy_require_mfa_for_admin_portals, + ) + from prowler.providers.azure.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + policy = ConditionalAccessPolicy( + id=policy_id, + name="Test Policy", + state="enabled", + users={"include": ["All"]}, + target_resources={"include": ["MicrosoftAdminPortals"]}, + access_controls={"grant": ["grant"]}, + ) + + entra_client.conditional_access_policy = {DOMAIN: {policy_id: policy}} + + check = entra_conditional_access_policy_require_mfa_for_admin_portals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_name == "Conditional Access Policy" + assert result[0].resource_id == "Conditional Access Policy" + assert ( + result[0].status_extended + == "Conditional Access Policy does not require MFA for Microsoft Admin Portals." + ) + + def test_entra_tenant_policy_mfa(self): + entra_client = mock.MagicMock + policy_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals import ( + entra_conditional_access_policy_require_mfa_for_admin_portals, + ) + from prowler.providers.azure.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + policy = ConditionalAccessPolicy( + id=policy_id, + name="Test Policy", + state="enabled", + users={"include": ["All"]}, + target_resources={"include": ["MicrosoftAdminPortals"]}, + access_controls={"grant": ["grant", "MFA"]}, + ) + + entra_client.conditional_access_policy = {DOMAIN: {policy_id: policy}} + + check = entra_conditional_access_policy_require_mfa_for_admin_portals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_name == "Test Policy" + assert result[0].resource_id == policy_id + assert ( + result[0].status_extended + == "Conditional Access Policy requires MFA for Microsoft Admin Portals." + ) + + def test_entra_tenant_policy_mfa_disabled(self): + entra_client = mock.MagicMock + policy_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals import ( + entra_conditional_access_policy_require_mfa_for_admin_portals, + ) + from prowler.providers.azure.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + policy = ConditionalAccessPolicy( + id=policy_id, + name="Test Policy", + state="disabled", + users={"include": ["All"]}, + target_resources={"include": ["MicrosoftAdminPortals"]}, + access_controls={"grant": ["grant", "MFA"]}, + ) + + entra_client.conditional_access_policy = {DOMAIN: {policy_id: policy}} + + check = entra_conditional_access_policy_require_mfa_for_admin_portals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_name == "Conditional Access Policy" + assert result[0].resource_id == "Conditional Access Policy" + assert ( + result[0].status_extended + == "Conditional Access Policy does not require MFA for Microsoft Admin Portals." + ) + + def test_entra_tenant_policy_mfa_no_target(self): + entra_client = mock.MagicMock + policy_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals import ( + entra_conditional_access_policy_require_mfa_for_admin_portals, + ) + from prowler.providers.azure.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + policy = ConditionalAccessPolicy( + id=policy_id, + name="Test Policy", + state="enabled", + users={"include": ["All"]}, + target_resources={"include": []}, + access_controls={"grant": ["grant", "MFA"]}, + ) + + entra_client.conditional_access_policy = {DOMAIN: {policy_id: policy}} + + check = entra_conditional_access_policy_require_mfa_for_admin_portals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_name == "Conditional Access Policy" + assert result[0].resource_id == "Conditional Access Policy" + assert ( + result[0].status_extended + == "Conditional Access Policy does not require MFA for Microsoft Admin Portals." + ) + + def test_entra_tenant_policy_mfa_no_users(self): + entra_client = mock.MagicMock + policy_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_conditional_access_policy_require_mfa_for_admin_portals.entra_conditional_access_policy_require_mfa_for_admin_portals import ( + entra_conditional_access_policy_require_mfa_for_admin_portals, + ) + from prowler.providers.azure.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + policy = ConditionalAccessPolicy( + id=policy_id, + name="Test Policy", + state="enabled", + users={"include": []}, + target_resources={"include": ["MicrosoftAdminPortals"]}, + access_controls={"grant": ["grant", "MFA"]}, + ) + + entra_client.conditional_access_policy = {DOMAIN: {policy_id: policy}} + + check = entra_conditional_access_policy_require_mfa_for_admin_portals() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == f"Tenant: {DOMAIN}" + assert result[0].resource_name == "Conditional Access Policy" + assert result[0].resource_id == "Conditional Access Policy" + assert ( + result[0].status_extended + == "Conditional Access Policy does not require MFA for Microsoft Admin Portals." + ) 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 4667b665ed..4d2f289a90 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 @@ -142,6 +142,86 @@ class Test_entra_non_privileged_user_has_mfa: assert result[0].resource_id == user_id assert result[0].subscription == f"Tenant: {DOMAIN}" + def test_entra_disabled_user_no_privileged_no_mfa(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import ( + entra_non_privileged_user_has_mfa, + ) + from prowler.providers.azure.services.entra.entra_service import ( + DirectoryRole, + User, + ) + + user = User( + id=user_id, + name="foo", + is_mfa_capable=False, + account_enabled=False, + ) + + entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} + entra_client.directory_roles = { + DOMAIN: { + "Global Administrator": DirectoryRole(id=str(uuid4()), members=[]) + } + } + + check = entra_non_privileged_user_has_mfa() + result = check.execute() + assert len(result) == 0 + + def test_entra_disabled_user_no_privileged_mfa(self): + entra_client = mock.MagicMock + user_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_non_privileged_user_has_mfa.entra_non_privileged_user_has_mfa import ( + entra_non_privileged_user_has_mfa, + ) + from prowler.providers.azure.services.entra.entra_service import ( + DirectoryRole, + User, + ) + + user = User( + id=user_id, + name="foo", + is_mfa_capable=True, + account_enabled=False, + ) + + entra_client.users = {DOMAIN: {f"foo@{DOMAIN}": user}} + entra_client.directory_roles = { + DOMAIN: { + "Global Administrator": DirectoryRole(id=str(uuid4()), members=[]) + } + } + + check = entra_non_privileged_user_has_mfa() + result = check.execute() + assert len(result) == 0 + def test_entra_user_privileged_no_mfa(self): entra_client = mock.MagicMock user_id = str(uuid4()) diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index 8e3a25e59f..75ef4f98c4 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -147,6 +147,7 @@ class Test_Entra_Service: assert entra_client.users[DOMAIN]["user-1@tenant1.es"].id == "id-1" assert entra_client.users[DOMAIN]["user-1@tenant1.es"].name == "User 1" assert entra_client.users[DOMAIN]["user-1@tenant1.es"].is_mfa_capable is False + assert entra_client.users[DOMAIN]["user-1@tenant1.es"].account_enabled is True def test_get_authorization_policy(self): entra_client = Entra(set_mocked_azure_provider()) @@ -229,8 +230,8 @@ def test_azure_entra__get_users_handles_pagination(): entra_service = Entra.__new__(Entra) users_page_one = [ - SimpleNamespace(id="user-1", display_name="User 1"), - SimpleNamespace(id="user-2", display_name="User 2"), + SimpleNamespace(id="user-1", display_name="User 1", account_enabled=False), + SimpleNamespace(id="user-2", display_name="User 2", account_enabled=True), ] users_page_two = [ SimpleNamespace(id="user-3", display_name="User 3"), @@ -288,9 +289,18 @@ def test_azure_entra__get_users_handles_pagination(): assert len(users["tenant-1"]) == 3 assert users_builder.get.await_count == 1 + request_configuration = users_builder.get.await_args.kwargs["request_configuration"] + assert request_configuration.query_parameters.select == [ + "id", + "displayName", + "accountEnabled", + ] with_url_mock.assert_called_once_with("next-link") registration_details_builder.get.assert_awaited() registration_details_builder.with_url.assert_not_called() assert users["tenant-1"]["user-1"].is_mfa_capable is True + assert users["tenant-1"]["user-1"].account_enabled is False assert users["tenant-1"]["user-2"].is_mfa_capable is True + assert users["tenant-1"]["user-2"].account_enabled is True assert users["tenant-1"]["user-3"].is_mfa_capable is False + assert users["tenant-1"]["user-3"].account_enabled is True diff --git a/tests/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac_test.py b/tests/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac_test.py index 8c9901080b..5995971555 100644 --- a/tests/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_key_expiration_set_in_non_rbac/keyvault_key_expiration_set_in_non_rbac_test.py @@ -127,17 +127,18 @@ class Test_keyvault_key_expiration_set_in_non_rbac: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the key {key_name} without expiration date set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id + assert result[0].resource_name == key_name + assert result[0].resource_id == "id" assert result[0].location == "westeurope" def test_key_vaults_valid_keys(self): keyvault_client = mock.MagicMock keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) + key_name = "Key Name" with ( mock.patch( @@ -159,7 +160,7 @@ class Test_keyvault_key_expiration_set_in_non_rbac: key = Key( id="id", - name="name", + name=key_name, enabled=True, location="westeurope", attributes=KeyAttributes(expires=49394, enabled=True), @@ -187,9 +188,126 @@ class Test_keyvault_key_expiration_set_in_non_rbac: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has all the keys with expiration date set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id + assert result[0].resource_name == key_name + assert result[0].resource_id == "id" assert result[0].location == "westeurope" + + def test_disabled_key_skipped(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_key_expiration_set_in_non_rbac.keyvault_key_expiration_set_in_non_rbac.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_key_expiration_set_in_non_rbac.keyvault_key_expiration_set_in_non_rbac import ( + keyvault_key_expiration_set_in_non_rbac, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + key = Key( + id="id", + name="disabled_key", + enabled=False, + location="westeurope", + attributes=KeyAttributes(expires=None, enabled=False), + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[key], + secrets=[], + ) + ] + } + check = keyvault_key_expiration_set_in_non_rbac() + result = check.execute() + assert len(result) == 0 + + def test_multiple_keys_mixed_expiration(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + key_with_expiry = "key_with_expiry" + key_without_expiry = "key_without_expiry" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_key_expiration_set_in_non_rbac.keyvault_key_expiration_set_in_non_rbac.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_key_expiration_set_in_non_rbac.keyvault_key_expiration_set_in_non_rbac import ( + keyvault_key_expiration_set_in_non_rbac, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[ + Key( + id="id1", + name=key_without_expiry, + enabled=True, + location="westeurope", + attributes=KeyAttributes(expires=None, enabled=True), + ), + Key( + id="id2", + name=key_with_expiry, + enabled=True, + location="westeurope", + attributes=KeyAttributes(expires=49394, enabled=True), + ), + ], + secrets=[], + ) + ] + } + check = keyvault_key_expiration_set_in_non_rbac() + result = check.execute() + assert len(result) == 2 + assert result[0] is not result[1] + assert result[0].status == "FAIL" + assert key_without_expiry in result[0].status_extended + assert result[1].status == "PASS" + assert key_with_expiry in result[1].status_extended diff --git a/tests/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled_test.py b/tests/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled_test.py index cb4224a12f..75d1e922e2 100644 --- a/tests/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_key_rotation_enabled/keyvault_key_rotation_enabled_test.py @@ -128,12 +128,12 @@ class Test_keyvault_key_rotation_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the key {key_name} without rotation policy set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have a rotation policy set." ) - assert result[0].resource_name == keyvault_name + assert result[0].resource_name == key_name assert result[0].resource_id == "id" assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].location == "westeurope" + assert result[0].location == "location" def test_key_with_rotation_policy(self): keyvault_client = mock.MagicMock @@ -198,9 +198,179 @@ class Test_keyvault_key_rotation_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the key {key_name} with rotation policy set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has a rotation policy set." ) - assert result[0].resource_name == keyvault_name + assert result[0].resource_name == key_name assert result[0].resource_id == "id" assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].location == "westeurope" + assert result[0].location == "location" + + def test_multiple_keys_mixed_rotation_policies(self): + keyvault_client = mock.MagicMock + keyvault_name = "keyvault_name" + key_with_rotation = "key_with_rotation" + key_without_rotation = "key_without_rotation" + key_with_notify_only = "key_with_notify_only" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_key_rotation_enabled.keyvault_key_rotation_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_key_rotation_enabled.keyvault_key_rotation_enabled import ( + keyvault_key_rotation_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[ + Key( + id="id1", + name=key_with_rotation, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + rotation_policy=KeyRotationPolicy( + lifetime_actions=[ + KeyRotationLifetimeAction( + action="Rotate", + lifetime_action_type="Rotate", + lifetime_percentage=80, + ) + ] + ), + ), + Key( + id="id2", + name=key_without_rotation, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + rotation_policy=None, + ), + Key( + id="id3", + name=key_with_notify_only, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + rotation_policy=KeyRotationPolicy( + lifetime_actions=[ + KeyRotationLifetimeAction( + action="Notify", + lifetime_action_type="Notify", + lifetime_percentage=90, + ) + ] + ), + ), + ], + secrets=[], + ) + ] + } + check = keyvault_key_rotation_enabled() + result = check.execute() + assert len(result) == 3 + # Each finding must be a distinct object + assert result[0] is not result[1] + assert result[1] is not result[2] + # Key with rotation policy -> PASS + assert result[0].status == "PASS" + assert key_with_rotation in result[0].status_extended + # Key without rotation policy -> FAIL + assert result[1].status == "FAIL" + assert key_without_rotation in result[1].status_extended + # Key with only Notify action (no Rotate) -> FAIL + assert result[2].status == "FAIL" + assert key_with_notify_only in result[2].status_extended + + def test_rotation_action_not_first_in_lifetime_actions(self): + keyvault_client = mock.MagicMock + keyvault_name = "keyvault_name" + key_name = "key_name" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_key_rotation_enabled.keyvault_key_rotation_enabled.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_key_rotation_enabled.keyvault_key_rotation_enabled import ( + keyvault_key_rotation_enabled, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id="id", + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[ + Key( + id="id", + name=key_name, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + rotation_policy=KeyRotationPolicy( + lifetime_actions=[ + KeyRotationLifetimeAction( + action="Notify", + lifetime_action_type="Notify", + lifetime_percentage=90, + ), + KeyRotationLifetimeAction( + action="Rotate", + lifetime_action_type="Rotate", + lifetime_percentage=80, + ), + ] + ), + ) + ], + secrets=[], + ) + ] + } + check = keyvault_key_rotation_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has a rotation policy set." + ) 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 1bbc078c08..7594860d76 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 @@ -9,7 +9,7 @@ from tests.providers.azure.azure_fixtures import ( class Test_keyvault_logging_enabled: - def test_keyvault_logging_enabled(self): + def test_no_key_vaults(self): keyvault_client = mock.MagicMock keyvault_client.key_vaults = {} @@ -27,7 +27,6 @@ class Test_keyvault_logging_enabled: new=keyvault_client, ), ): - from prowler.providers.azure.services.keyvault.keyvault_logging_enabled.keyvault_logging_enabled import ( keyvault_logging_enabled, ) @@ -38,7 +37,7 @@ class Test_keyvault_logging_enabled: def test_no_diagnostic_settings(self): keyvault_client = mock.MagicMock - keyvault_client.key_vaults = {AZURE_SUBSCRIPTION_ID: []} + with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -56,12 +55,40 @@ class Test_keyvault_logging_enabled: 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, + ) + 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=[], + ), + ] + } check = keyvault_logging_enabled() result = check.execute() - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_ID} does not have a diagnostic setting with audit logging." + ) + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" - def test_diagnostic_settings_configured(self): + def test_diagnostic_setting_without_audit_logging(self): keyvault_client = mock.MagicMock with ( @@ -104,7 +131,7 @@ class Test_keyvault_logging_enabled: secrets=[], monitor_diagnostic_settings=[ DiagnosticSetting( - id="id/id", + id="id/ds1", logs=[ mock.MagicMock( category_group="audit", @@ -117,17 +144,59 @@ class Test_keyvault_logging_enabled: enabled=False, ), ], - storage_account_name="storage_account_name", - storage_account_id="storage_account_id", - name="name_diagnostic_setting", + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_incomplete", ), ], ), + ] + } + 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_ID} does not have 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_logging(self): + keyvault_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.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="id2", - name="name_keyvault2", - location="eastus", - resource_group="resource_group2", + id="id", + name="name_keyvault", + location="westeurope", + resource_group="resource_group", properties=VaultProperties( tenant_id="tenantid", sku="sku", @@ -137,7 +206,7 @@ class Test_keyvault_logging_enabled: secrets=[], monitor_diagnostic_settings=[ DiagnosticSetting( - id="id2/id2", + id="id/ds1", logs=[ mock.MagicMock( category_group="audit", @@ -150,9 +219,206 @@ class Test_keyvault_logging_enabled: enabled=True, ), ], - storage_account_name="storage_account_name2", - storage_account_id="storage_account_id2", - name="name_diagnostic_setting2", + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_compliant", + ), + ], + ), + ] + } + 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_ID} has 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 + + 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="audit", + category="None", + enabled=False, + ), + mock.MagicMock( + category_group="allLogs", + category="None", + enabled=False, + ), + ], + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_noncompliant", + ), + DiagnosticSetting( + id="id/ds2", + logs=[ + mock.MagicMock( + category_group="audit", + category="None", + enabled=True, + ), + mock.MagicMock( + category_group="allLogs", + category="None", + enabled=True, + ), + ], + storage_account_name="sa2", + storage_account_id="sa_id2", + name="ds_compliant", + ), + ], + ), + ] + } + check = keyvault_logging_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "name_keyvault" + assert result[0].resource_id == "id" + + def test_multiple_vaults_mixed(self): + keyvault_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.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="id1", + name="vault_fail", + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id1/ds1", + logs=[ + mock.MagicMock( + category_group="audit", + category="None", + enabled=True, + ), + mock.MagicMock( + category_group="allLogs", + category="None", + enabled=False, + ), + ], + storage_account_name="sa1", + storage_account_id="sa_id1", + name="ds_incomplete", + ), + ], + ), + KeyVaultInfo( + id="id2", + name="vault_pass", + location="eastus", + resource_group="resource_group2", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[], + monitor_diagnostic_settings=[ + DiagnosticSetting( + id="id2/ds2", + logs=[ + mock.MagicMock( + category_group="audit", + category="None", + enabled=True, + ), + mock.MagicMock( + category_group="allLogs", + category="None", + enabled=True, + ), + ], + storage_account_name="sa2", + storage_account_id="sa_id2", + name="ds_compliant", ), ], ), @@ -162,20 +428,8 @@ class Test_keyvault_logging_enabled: result = check.execute() assert len(result) == 2 assert result[0].status == "FAIL" - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "name_diagnostic_setting" - assert result[0].resource_id == "id/id" - assert result[0].location == "westeurope" - assert ( - result[0].status_extended - == f"Diagnostic setting name_diagnostic_setting for Key Vault name_keyvault in subscription {AZURE_SUBSCRIPTION_ID} does not have audit logging." - ) + assert result[0].resource_name == "vault_fail" + assert result[0].resource_id == "id1" assert result[1].status == "PASS" - assert result[1].subscription == AZURE_SUBSCRIPTION_ID - assert result[1].resource_name == "name_diagnostic_setting2" - assert result[1].resource_id == "id2/id2" - assert result[1].location == "eastus" - assert ( - result[1].status_extended - == f"Diagnostic setting name_diagnostic_setting2 for Key Vault name_keyvault2 in subscription {AZURE_SUBSCRIPTION_ID} has audit logging." - ) + assert result[1].resource_name == "vault_pass" + assert result[1].resource_id == "id2" diff --git a/tests/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set_test.py b/tests/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set_test.py index 9a799a1a06..22b7664ec4 100644 --- a/tests/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_non_rbac_secret_expiration_set/keyvault_non_rbac_secret_expiration_set_test.py @@ -128,12 +128,12 @@ class Test_keyvault_non_rbac_secret_expiration_set: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the secret {secret_name} without expiration date set." + == f"Secret {secret_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert result[0].resource_name == secret_name + assert result[0].resource_id == "id" + assert result[0].location == "location" def test_key_vaults_invalid_multiple_secrets(self): keyvault_client = mock.MagicMock @@ -193,21 +193,18 @@ class Test_keyvault_non_rbac_secret_expiration_set: } check = keyvault_non_rbac_secret_expiration_set() result = check.execute() - assert len(result) == 1 + assert len(result) == 2 + assert result[0] is not result[1] assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the secret {secret1_name} without expiration date set." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert secret1_name in result[0].status_extended + assert result[1].status == "PASS" + assert secret2_name in result[1].status_extended - def test_key_vaults_valid_keys(self): + def test_key_vaults_valid_secrets(self): keyvault_client = mock.MagicMock keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) + secret_name = "name" with ( mock.patch( @@ -229,10 +226,10 @@ class Test_keyvault_non_rbac_secret_expiration_set: secret = Secret( id="id", - name="name", - enabled=False, + name=secret_name, + enabled=True, location="location", - attributes=SecretAttributes(expires=None), + attributes=SecretAttributes(expires=84934), ) keyvault_client.key_vaults = { AZURE_SUBSCRIPTION_ID: [ @@ -257,9 +254,60 @@ class Test_keyvault_non_rbac_secret_expiration_set: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has all the secrets with expiration date set." + == f"Secret {secret_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert result[0].resource_name == secret_name + assert result[0].resource_id == "id" + assert result[0].location == "location" + + def test_disabled_secret_skipped(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_non_rbac_secret_expiration_set.keyvault_non_rbac_secret_expiration_set.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_non_rbac_secret_expiration_set.keyvault_non_rbac_secret_expiration_set import ( + keyvault_non_rbac_secret_expiration_set, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + Secret, + ) + + secret = Secret( + id="id", + name="disabled_secret", + enabled=False, + location="location", + attributes=SecretAttributes(expires=None), + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=False, + ), + keys=[], + secrets=[secret], + ) + ] + } + check = keyvault_non_rbac_secret_expiration_set() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set_test.py b/tests/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set_test.py index c9034e7e87..c12c000526 100644 --- a/tests/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_rbac_key_expiration_set/keyvault_rbac_key_expiration_set_test.py @@ -127,17 +127,18 @@ class Test_keyvault_rbac_key_expiration_set: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has the key {key_name} without expiration date set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert result[0].resource_name == key_name + assert result[0].resource_id == "id" + assert result[0].location == "location" def test_key_vaults_valid_keys(self): keyvault_client = mock.MagicMock keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) + key_name = "Key Name" with ( mock.patch( @@ -159,7 +160,7 @@ class Test_keyvault_rbac_key_expiration_set: key = Key( id="id", - name="name", + name=key_name, enabled=True, location="location", attributes=KeyAttributes(expires=49394, enabled=True), @@ -187,9 +188,126 @@ class Test_keyvault_rbac_key_expiration_set: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has all the keys with expiration date set." + == f"Key {key_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == keyvault_name - assert result[0].resource_id == keyvault_id - assert result[0].location == "westeurope" + assert result[0].resource_name == key_name + assert result[0].resource_id == "id" + assert result[0].location == "location" + + def test_disabled_key_skipped(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_rbac_key_expiration_set.keyvault_rbac_key_expiration_set.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_rbac_key_expiration_set.keyvault_rbac_key_expiration_set import ( + keyvault_rbac_key_expiration_set, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + key = Key( + id="id", + name="disabled_key", + enabled=False, + location="location", + attributes=KeyAttributes(expires=None, enabled=False), + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=True, + ), + keys=[key], + secrets=[], + ) + ] + } + check = keyvault_rbac_key_expiration_set() + result = check.execute() + assert len(result) == 0 + + def test_multiple_keys_mixed_expiration(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + key_with_expiry = "key_with_expiry" + key_without_expiry = "key_without_expiry" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_rbac_key_expiration_set.keyvault_rbac_key_expiration_set.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_rbac_key_expiration_set.keyvault_rbac_key_expiration_set import ( + keyvault_rbac_key_expiration_set, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + Key, + KeyVaultInfo, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=True, + ), + keys=[ + Key( + id="id1", + name=key_without_expiry, + enabled=True, + location="location", + attributes=KeyAttributes(expires=None, enabled=True), + ), + Key( + id="id2", + name=key_with_expiry, + enabled=True, + location="location", + attributes=KeyAttributes(expires=49394, enabled=True), + ), + ], + secrets=[], + ) + ] + } + check = keyvault_rbac_key_expiration_set() + result = check.execute() + assert len(result) == 2 + assert result[0] is not result[1] + assert result[0].status == "FAIL" + assert key_without_expiry in result[0].status_extended + assert result[1].status == "PASS" + assert key_with_expiry in result[1].status_extended diff --git a/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py b/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py index d46bc7b223..d6b78380bc 100644 --- a/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py +++ b/tests/providers/azure/services/keyvault/keyvault_rbac_secret_expiration_set/keyvault_rbac_secret_expiration_set_test.py @@ -97,12 +97,11 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) - secret_id = str(uuid4()) secret = Secret( - id=secret_id, + id="id", name=secret_name, enabled=True, - location="westeurope", + location="location", attributes=SecretAttributes(expires=None), ) keyvault_client.key_vaults = { @@ -128,12 +127,12 @@ class Test_keyvault_rbac_secret_expiration_set: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Secret '{secret_name}' in KeyVault '{keyvault_name}' does not have expiration date set." + == f"Secret {secret_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == secret_name - assert result[0].resource_id == secret_id - assert result[0].location == "westeurope" + assert result[0].resource_id == "id" + assert result[0].location == "location" def test_key_vaults_invalid_multiple_secrets(self): keyvault_client = mock.MagicMock @@ -160,20 +159,18 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) - secret1_id = str(uuid4()) - secret2_id = str(uuid4()) secret1 = Secret( - id=secret1_id, + id="id", name=secret1_name, enabled=True, - location="westeurope", + location="location", attributes=SecretAttributes(expires=None), ) secret2 = Secret( - id=secret2_id, + id="id", name=secret2_name, enabled=True, - location="westeurope", + location="location", attributes=SecretAttributes(expires=84934), ) keyvault_client.key_vaults = { @@ -195,40 +192,18 @@ class Test_keyvault_rbac_secret_expiration_set: } check = keyvault_rbac_secret_expiration_set() result = check.execute() - # Now we get 1 finding per secret (2 total) assert len(result) == 2 + assert result[0] is not result[1] + assert result[0].status == "FAIL" + assert secret1_name in result[0].status_extended + assert result[1].status == "PASS" + assert secret2_name in result[1].status_extended - # Find the FAIL and PASS results by status - fail_results = [r for r in result if r.status == "FAIL"] - pass_results = [r for r in result if r.status == "PASS"] - - assert len(fail_results) == 1 - assert len(pass_results) == 1 - - # Verify FAIL finding (secret1 without expiration) - assert ( - fail_results[0].status_extended - == f"Secret '{secret1_name}' in KeyVault '{keyvault_name}' does not have expiration date set." - ) - assert fail_results[0].subscription == AZURE_SUBSCRIPTION_ID - assert fail_results[0].resource_name == secret1_name - assert fail_results[0].resource_id == secret1_id - assert fail_results[0].location == "westeurope" - - # Verify PASS finding (secret2 with expiration) - assert ( - pass_results[0].status_extended - == f"Secret '{secret2_name}' in KeyVault '{keyvault_name}' has expiration date set." - ) - assert pass_results[0].subscription == AZURE_SUBSCRIPTION_ID - assert pass_results[0].resource_name == secret2_name - assert pass_results[0].resource_id == secret2_id - assert pass_results[0].location == "westeurope" - - def test_key_vaults_valid_keys(self): + def test_key_vaults_valid_secrets(self): keyvault_client = mock.MagicMock keyvault_name = "Keyvault Name" keyvault_id = str(uuid4()) + secret_name = "name" with ( mock.patch( @@ -248,14 +223,12 @@ class Test_keyvault_rbac_secret_expiration_set: Secret, ) - secret_name = "secret-name" - secret_id = str(uuid4()) secret = Secret( - id=secret_id, + id="id", name=secret_name, - enabled=False, - location="westeurope", - attributes=SecretAttributes(expires=None), + enabled=True, + location="location", + attributes=SecretAttributes(expires=84934), ) keyvault_client.key_vaults = { AZURE_SUBSCRIPTION_ID: [ @@ -280,9 +253,60 @@ class Test_keyvault_rbac_secret_expiration_set: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Secret '{secret_name}' in KeyVault '{keyvault_name}' has expiration date set." + == f"Secret {secret_name} in Key Vault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has an expiration date set." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == secret_name - assert result[0].resource_id == secret_id - assert result[0].location == "westeurope" + assert result[0].resource_id == "id" + assert result[0].location == "location" + + def test_disabled_secret_skipped(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_rbac_secret_expiration_set.keyvault_rbac_secret_expiration_set.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_rbac_secret_expiration_set.keyvault_rbac_secret_expiration_set import ( + keyvault_rbac_secret_expiration_set, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + Secret, + ) + + secret = Secret( + id="id", + name="disabled_secret", + enabled=False, + location="location", + attributes=SecretAttributes(expires=None), + ) + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + sku="sku", + enable_rbac_authorization=True, + ), + keys=[], + secrets=[secret], + ) + ] + } + check = keyvault_rbac_secret_expiration_set() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py index 84daaa758c..47ef92551b 100644 --- a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_connection_activated/mysql_flexible_server_audit_log_connection_activated_test.py @@ -56,6 +56,57 @@ class Test_mysql_flexible_server_audit_log_connection_activated: result = check.execute() assert len(result) == 0 + def test_mysql_audit_log_connection_activated_lowercase(self): + server_name = str(uuid4()) + mysql_client = mock.MagicMock + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + "/subscriptions/resource_id": FlexibleServer( + resource_id="/subscriptions/resource_id", + name=server_name, + location="location", + version="version", + configurations={ + "audit_log_events": Configuration( + resource_id=f"/subscriptions/{server_name}/configurations/audit_log_events", + description="description", + value="connection", + ) + }, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_connection_activated.mysql_flexible_server_audit_log_connection_activated.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_connection_activated.mysql_flexible_server_audit_log_connection_activated import ( + mysql_flexible_server_audit_log_connection_activated, + ) + + check = mysql_flexible_server_audit_log_connection_activated() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].location == "location" + assert ( + result[0].resource_id + == f"/subscriptions/{server_name}/configurations/audit_log_events" + ) + assert ( + result[0].status_extended + == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + ) + def test_mysql_audit_log_connection_not_connection(self): server_name = str(uuid4()) mysql_client = mock.MagicMock diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py index ad243c5807..7c32f337fd 100644 --- a/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_audit_log_enabled/mysql_flexible_server_audit_log_enabled_test.py @@ -56,6 +56,57 @@ class Test_mysql_flexible_server_audit_log_enabled: result = check.execute() assert len(result) == 0 + def test_mysql_audit_log_enabled_lowercase(self): + server_name = str(uuid4()) + mysql_client = mock.MagicMock + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + "/subscriptions/resource_id": FlexibleServer( + resource_id="/subscriptions/resource_id", + name=server_name, + location="location", + version="version", + configurations={ + "audit_log_enabled": Configuration( + resource_id=f"/subscriptions/{server_name}/configurations/audit_log_enabled", + description="description", + value="on", + ) + }, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_enabled.mysql_flexible_server_audit_log_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_audit_log_enabled.mysql_flexible_server_audit_log_enabled import ( + mysql_flexible_server_audit_log_enabled, + ) + + check = mysql_flexible_server_audit_log_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].location == "location" + assert ( + result[0].resource_id + == f"/subscriptions/{server_name}/configurations/audit_log_enabled" + ) + assert ( + result[0].status_extended + == f"Audit log is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + ) + def test_mysql_audit_log_disabled(self): server_name = str(uuid4()) mysql_client = mock.MagicMock diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py index f540fe4865..2b87a28d8f 100644 --- a/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_ssl_connection_enabled/mysql_flexible_server_ssl_connection_enabled_test.py @@ -107,6 +107,57 @@ class Test_mysql_flexible_server_ssl_connection_enabled: == f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." ) + def test_mysql_connection_enabled_lowercase(self): + server_name = str(uuid4()) + mysql_client = mock.MagicMock + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + "/subscriptions/resource_id": FlexibleServer( + resource_id="/subscriptions/resource_id", + name=server_name, + location="location", + version="version", + configurations={ + "require_secure_transport": Configuration( + resource_id=f"/subscriptions/{server_name}/configurations/require_secure_transport", + description="description", + value="on", + ) + }, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_ssl_connection_enabled.mysql_flexible_server_ssl_connection_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_ssl_connection_enabled.mysql_flexible_server_ssl_connection_enabled import ( + mysql_flexible_server_ssl_connection_enabled, + ) + + check = mysql_flexible_server_ssl_connection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].location == "location" + assert ( + result[0].resource_id + == f"/subscriptions/{server_name}/configurations/require_secure_transport" + ) + assert ( + result[0].status_extended + == f"SSL connection is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_ID}." + ) + def test_mysql_ssl_connection_disabled(self): server_name = str(uuid4()) mysql_client = mock.MagicMock diff --git a/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py b/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py index 23394addca..a99be2ea54 100644 --- a/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py +++ b/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py @@ -221,6 +221,85 @@ class Test_vm_backup_enabled: == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is not protected by Azure Backup." ) + def test_vm_protected_by_backup_case_insensitive(self): + vm_id = str(uuid4()) + vm_name = "vmtest" + vault_id = str(uuid4()) + vault_name = "vault1" + mock_vm_client = mock.MagicMock() + mock_recovery_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from azure.mgmt.recoveryservicesbackup.activestamp.models import ( + DataSourceType, + ) + + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + from prowler.providers.azure.services.vm.vm_service import ( + ManagedDiskParameters, + OSDisk, + StorageProfile, + VirtualMachine, + ) + + vm = VirtualMachine( + resource_id=vm_id, + resource_name=vm_name, + location="eastus", + security_profile=None, + extensions=[], + storage_profile=StorageProfile( + os_disk=OSDisk( + name="os_disk_name", + operating_system_type="Linux", + managed_disk=ManagedDiskParameters(id="managed_disk_id"), + ), + data_disks=[], + ), + ) + backup_item = BackupItem( + id=str(uuid4()), + name="someprefix;VMTEST", + workload_type=DataSourceType.VM, + ) + vault = BackupVault( + id=vault_id, + name=vault_name, + location="eastus", + backup_protected_items={backup_item.id: backup_item}, + ) + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == vm_name + assert result[0].resource_id == vm_id + assert ( + result[0].status_extended + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is protected by Azure Backup (vault: {vault_name})." + ) + def test_vm_protected_by_backup_non_vm_workload(self): vm_id = str(uuid4()) vm_name = "VMTest" diff --git a/tests/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period_test.py b/tests/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period_test.py index b43b75548a..28aab1b38b 100644 --- a/tests/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period_test.py +++ b/tests/providers/azure/services/vm/vm_sufficient_daily_backup_retention_period/vm_sufficient_daily_backup_retention_period_test.py @@ -156,6 +156,100 @@ class Test_vm_sufficient_daily_backup_retention_period: in result[0].status_extended ) + def test_vm_with_sufficient_retention_case_insensitive(self): + from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType + + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupPolicy, + BackupVault, + ) + from prowler.providers.azure.services.vm.vm_service import ( + ManagedDiskParameters, + OSDisk, + StorageProfile, + VirtualMachine, + ) + + vm_id = str(uuid4()) + vm_name = "vmtest" + vault_id = str(uuid4()) + policy_id = str(uuid4()) + retention_days = 14 + min_retention_days = 7 + + vm = VirtualMachine( + resource_id=vm_id, + resource_name=vm_name, + location="eastus", + security_profile=None, + extensions=[], + storage_profile=StorageProfile( + os_disk=OSDisk( + name="os_disk_name", + operating_system_type="Linux", + managed_disk=ManagedDiskParameters(id="managed_disk_id"), + ), + data_disks=[], + ), + ) + backup_item = BackupItem( + id=str(uuid4()), + name="someprefix;VMTEST", + workload_type=DataSourceType.VM, + backup_policy_id=policy_id, + ) + backup_policy = BackupPolicy( + id=policy_id, + name="policy1", + retention_days=retention_days, + ) + vault = BackupVault( + id=vault_id, + name="vault1", + location="eastus", + backup_protected_items={backup_item.id: backup_item}, + backup_policies={policy_id: backup_policy}, + ) + vm_client = mock.MagicMock() + recovery_client = mock.MagicMock() + vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} + vm_client.audit_config = { + "vm_backup_min_daily_retention_days": min_retention_days + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider( + audit_config=vm_client.audit_config + ), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.vm_client", + new=vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.vm.vm_sufficient_daily_backup_retention_period.vm_sufficient_daily_backup_retention_period import ( + vm_sufficient_daily_backup_retention_period, + ) + + check = vm_sufficient_daily_backup_retention_period() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == vm_name + assert result[0].resource_id == vm_id + assert ( + f"has a daily backup retention period of {retention_days} days" + in result[0].status_extended + ) + def test_vm_with_insufficient_retention(self): from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType diff --git a/tests/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api_test.py b/tests/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api_test.py new file mode 100644 index 0000000000..46b4b746e9 --- /dev/null +++ b/tests/providers/gcp/services/apikeys/apikeys_api_restricted_with_gemini_api/apikeys_api_restricted_with_gemini_api_test.py @@ -0,0 +1,407 @@ +from re import search +from unittest import mock + +from prowler.providers.gcp.services.apikeys.apikeys_service import Key +from prowler.providers.gcp.services.serviceusage.serviceusage_service import Service +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class Test_apikeys_api_restricted_with_gemini_api: + def test_unrestricted_key_gemini_disabled(self): + key = Key( + name="test", + id="123", + creation_time="2026-02-01T11:21:41.627509Z", + restrictions={}, + project_id=GCP_PROJECT_ID, + ) + + apikeys_client = mock.MagicMock() + apikeys_client.project_ids = [GCP_PROJECT_ID] + apikeys_client.keys = [key] + apikeys_client.region = "global" + + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = {GCP_PROJECT_ID: []} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.apikeys_client", + new=apikeys_client, + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api import ( + apikeys_api_restricted_with_gemini_api, + ) + + check = apikeys_api_restricted_with_gemini_api() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + "Gemini .* API is not enabled", + result[0].status_extended, + ) + assert result[0].resource_id == key.id + + def test_no_keys(self): + apikeys_client = mock.MagicMock() + apikeys_client.project_ids = [GCP_PROJECT_ID] + apikeys_client.keys = [] + apikeys_client.region = "global" + + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = { + GCP_PROJECT_ID: [ + Service( + name="generativelanguage.googleapis.com", + title="Gemini API", + project_id=GCP_PROJECT_ID, + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.apikeys_client", + new=apikeys_client, + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api import ( + apikeys_api_restricted_with_gemini_api, + ) + + check = apikeys_api_restricted_with_gemini_api() + result = check.execute() + + assert len(result) == 0 + + def test_key_restricted_to_gemini_only(self): + key = Key( + name="test", + id="123", + creation_time="2026-02-01T11:21:41.627509Z", + restrictions={ + "apiTargets": [ + {"service": "generativelanguage.googleapis.com"}, + ] + }, + project_id=GCP_PROJECT_ID, + ) + + apikeys_client = mock.MagicMock() + apikeys_client.project_ids = [GCP_PROJECT_ID] + apikeys_client.keys = [key] + apikeys_client.region = "global" + + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = { + GCP_PROJECT_ID: [ + Service( + name="generativelanguage.googleapis.com", + title="Gemini API", + project_id=GCP_PROJECT_ID, + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.apikeys_client", + new=apikeys_client, + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api import ( + apikeys_api_restricted_with_gemini_api, + ) + + check = apikeys_api_restricted_with_gemini_api() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"API key {key.name} has restrictions configured", + result[0].status_extended, + ) + assert result[0].resource_id == key.id + + def test_key_restricted_to_bigquery_and_gemini(self): + key = Key( + name="test", + id="123", + creation_time="2026-02-01T11:21:41.627509Z", + restrictions={ + "apiTargets": [ + {"service": "bigquery.googleapis.com"}, + {"service": "generativelanguage.googleapis.com"}, + ] + }, + project_id=GCP_PROJECT_ID, + ) + + apikeys_client = mock.MagicMock() + apikeys_client.project_ids = [GCP_PROJECT_ID] + apikeys_client.keys = [key] + apikeys_client.region = "global" + + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = { + GCP_PROJECT_ID: [ + Service( + name="bigquery.googleapis.com", + title="BigQuery API", + project_id=GCP_PROJECT_ID, + ), + Service( + name="generativelanguage.googleapis.com", + title="Gemini API", + project_id=GCP_PROJECT_ID, + ), + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.apikeys_client", + new=apikeys_client, + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api import ( + apikeys_api_restricted_with_gemini_api, + ) + + check = apikeys_api_restricted_with_gemini_api() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"API key {key.name} has access to Gemini", + result[0].status_extended, + ) + assert result[0].resource_id == key.id + + def test_key_restricted_to_bigquery_only(self): + key = Key( + name="test", + id="123", + creation_time="2026-02-01T11:21:41.627509Z", + restrictions={ + "apiTargets": [ + {"service": "bigquery.googleapis.com"}, + ] + }, + project_id=GCP_PROJECT_ID, + ) + + apikeys_client = mock.MagicMock() + apikeys_client.project_ids = [GCP_PROJECT_ID] + apikeys_client.keys = [key] + apikeys_client.region = "global" + + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = { + GCP_PROJECT_ID: [ + Service( + name="bigquery.googleapis.com", + title="BigQuery API", + project_id=GCP_PROJECT_ID, + ), + Service( + name="generativelanguage.googleapis.com", + title="Gemini API", + project_id=GCP_PROJECT_ID, + ), + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.apikeys_client", + new=apikeys_client, + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api import ( + apikeys_api_restricted_with_gemini_api, + ) + + check = apikeys_api_restricted_with_gemini_api() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"API key {key.name} has restrictions configured", + result[0].status_extended, + ) + assert result[0].resource_id == key.id + + def test_key_restricted_to_cloudapis(self): + key = Key( + name="test", + id="123", + creation_time="2026-02-01T11:21:41.627509Z", + restrictions={ + "apiTargets": [ + {"service": "cloudapis.googleapis.com"}, + ] + }, + project_id=GCP_PROJECT_ID, + ) + + apikeys_client = mock.MagicMock() + apikeys_client.project_ids = [GCP_PROJECT_ID] + apikeys_client.keys = [key] + apikeys_client.region = "global" + + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = { + GCP_PROJECT_ID: [ + Service( + name="generativelanguage.googleapis.com", + title="Gemini API", + project_id=GCP_PROJECT_ID, + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.apikeys_client", + new=apikeys_client, + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api import ( + apikeys_api_restricted_with_gemini_api, + ) + + check = apikeys_api_restricted_with_gemini_api() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"API key {key.name} does not have restrictions configured", + result[0].status_extended, + ) + assert result[0].resource_id == key.id + + def test_two_keys_one_restricted_one_unrestricted(self): + key_restricted = Key( + name="restricted-key", + id="123", + creation_time="2026-02-01T11:21:41.627509Z", + restrictions={ + "apiTargets": [ + {"service": "bigquery.googleapis.com"}, + ] + }, + project_id=GCP_PROJECT_ID, + ) + + key_unrestricted = Key( + name="unrestricted-key", + id="456", + creation_time="2026-02-01T11:21:41.627509Z", + restrictions={}, + project_id=GCP_PROJECT_ID, + ) + + apikeys_client = mock.MagicMock() + apikeys_client.project_ids = [GCP_PROJECT_ID] + apikeys_client.keys = [key_restricted, key_unrestricted] + apikeys_client.region = "global" + + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = { + GCP_PROJECT_ID: [ + Service( + name="generativelanguage.googleapis.com", + title="Gemini API", + project_id=GCP_PROJECT_ID, + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.apikeys_client", + new=apikeys_client, + ), + mock.patch( + "prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.apikeys.apikeys_api_restricted_with_gemini_api.apikeys_api_restricted_with_gemini_api import ( + apikeys_api_restricted_with_gemini_api, + ) + + check = apikeys_api_restricted_with_gemini_api() + result = check.execute() + + assert len(result) == 2 + + assert result[0].status == "PASS" + assert result[0].resource_id == key_restricted.id + + assert result[1].status == "FAIL" + assert search( + f"API key {key_unrestricted.name} does not have restrictions configured", + result[1].status_extended, + ) + assert result[1].resource_id == key_unrestricted.id diff --git a/tests/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled_test.py b/tests/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled_test.py new file mode 100644 index 0000000000..4797c392f1 --- /dev/null +++ b/tests/providers/gcp/services/gemini/gemini_api_disabled/gemini_api_disabled_test.py @@ -0,0 +1,110 @@ +from unittest import mock + +from prowler.providers.gcp.models import GCPProject +from prowler.providers.gcp.services.serviceusage.serviceusage_service import Service +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class Test_gemini_api_disabled: + def test_gemini_api_disabled(self): + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = { + GCP_PROJECT_ID: [ + Service( + name="storage.googleapis.com", + title="Cloud Storage API", + project_id=GCP_PROJECT_ID, + ) + ] + } + serviceusage_client.project_ids = [GCP_PROJECT_ID] + serviceusage_client.region = "global" + serviceusage_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.gemini.gemini_api_disabled.gemini_api_disabled.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.gemini.gemini_api_disabled.gemini_api_disabled import ( + gemini_api_disabled, + ) + + check = gemini_api_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Gemini (Generative Language) API is disabled for project {GCP_PROJECT_ID}" + ) + assert result[0].resource_id == "generativelanguage.googleapis.com" + assert result[0].project_id == GCP_PROJECT_ID + + def test_gemini_api_enabled(self): + serviceusage_client = mock.MagicMock() + serviceusage_client.active_services = { + GCP_PROJECT_ID: [ + Service( + name="storage.googleapis.com", + title="Cloud Storage API", + project_id=GCP_PROJECT_ID, + ), + Service( + name="generativelanguage.googleapis.com", + title="Gemini (Generative Language) API", + project_id=GCP_PROJECT_ID, + ), + ] + } + serviceusage_client.project_ids = [GCP_PROJECT_ID] + serviceusage_client.region = "global" + serviceusage_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.gemini.gemini_api_disabled.gemini_api_disabled.serviceusage_client", + new=serviceusage_client, + ), + ): + from prowler.providers.gcp.services.gemini.gemini_api_disabled.gemini_api_disabled import ( + gemini_api_disabled, + ) + + check = gemini_api_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Gemini (Generative Language) API is enabled for project {GCP_PROJECT_ID}" + ) + assert result[0].resource_id == "generativelanguage.googleapis.com" + assert result[0].project_id == GCP_PROJECT_ID diff --git a/tests/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited_test.py b/tests/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited_test.py new file mode 100644 index 0000000000..a9991ab2d1 --- /dev/null +++ b/tests/providers/github/services/organization/organization_repository_deletion_limited/organization_repository_deletion_limited_test.py @@ -0,0 +1,186 @@ +from unittest import mock + +from prowler.providers.github.services.organization.organization_service import Org +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_organization_repository_deletion_limited: + def test_no_organizations(self): + organization_client = mock.MagicMock + organization_client.organizations = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import ( + organization_repository_deletion_limited, + ) + + check = organization_repository_deletion_limited() + result = check.execute() + assert len(result) == 0 + + def test_repository_deletion_disabled(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_delete_repositories=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import ( + organization_repository_deletion_limited, + ) + + check = organization_repository_deletion_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_name == org_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Organization {org_name} restricts repository deletion/transfer to trusted users." + ) + + def test_repository_deletion_enabled(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_delete_repositories=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import ( + organization_repository_deletion_limited, + ) + + check = organization_repository_deletion_limited() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_name == org_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Organization {org_name} allows members to delete/transfer repositories." + ) + + def test_repository_deletion_setting_not_available(self): + organization_client = mock.MagicMock + org_name = "test-organization" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name, + mfa_required=None, + members_can_delete_repositories=None, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import ( + organization_repository_deletion_limited, + ) + + check = organization_repository_deletion_limited() + result = check.execute() + assert len(result) == 0 + + def test_multiple_organizations_mixed_settings(self): + organization_client = mock.MagicMock + org_name_1 = "test-organization-1" + org_name_2 = "test-organization-2" + org_name_3 = "test-organization-3" + organization_client.organizations = { + 1: Org( + id=1, + name=org_name_1, + mfa_required=None, + members_can_delete_repositories=False, + ), + 2: Org( + id=2, + name=org_name_2, + mfa_required=None, + members_can_delete_repositories=True, + ), + 3: Org( + id=3, + name=org_name_3, + mfa_required=None, + members_can_delete_repositories=None, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited.organization_client", + new=organization_client, + ), + ): + from prowler.providers.github.services.organization.organization_repository_deletion_limited.organization_repository_deletion_limited import ( + organization_repository_deletion_limited, + ) + + check = organization_repository_deletion_limited() + result = check.execute() + assert len(result) == 2 + + # Find results by organization name + results_by_name = {r.resource_name: r for r in result} + + assert org_name_1 in results_by_name + assert results_by_name[org_name_1].status == "PASS" + + assert org_name_2 in results_by_name + assert results_by_name[org_name_2].status == "FAIL" + + # org_name_3 should not be in results because setting is None + assert org_name_3 not in results_by_name diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py index 318ae549bd..fc98c097d8 100644 --- a/tests/providers/iac/iac_provider_test.py +++ b/tests/providers/iac/iac_provider_test.py @@ -61,7 +61,9 @@ class TestIacProvider: assert report.check_metadata.CheckID == SAMPLE_FAILED_CHECK["ID"] assert report.check_metadata.CheckTitle == SAMPLE_FAILED_CHECK["Title"] assert report.check_metadata.Severity == "low" - assert report.check_metadata.RelatedUrl == SAMPLE_FAILED_CHECK["PrimaryURL"] + assert report.check_metadata.RelatedUrl == SAMPLE_FAILED_CHECK.get( + "PrimaryURL", "" + ) def test_iac_provider_process_finding_passed(self): """Test processing a passed finding""" diff --git a/tests/providers/iac/lib/arguments/iac_arguments_test.py b/tests/providers/iac/lib/arguments/iac_arguments_test.py index f5a2e7ca17..3b0568a1d5 100644 --- a/tests/providers/iac/lib/arguments/iac_arguments_test.py +++ b/tests/providers/iac/lib/arguments/iac_arguments_test.py @@ -33,12 +33,12 @@ def test_validate_arguments_mutual_exclusion(): assert msg == "" -def test_validate_arguments_export_ocsf_requires_provider_uid(): - # --export-ocsf without provider_uid should fail +def test_validate_arguments_push_to_cloud_requires_provider_uid(): + # --push-to-cloud without provider_uid should fail args = Args( scan_path=".", scan_repository_url=None, - export_ocsf=True, + push_to_cloud=True, provider_uid=None, ) valid, msg = iac_arguments.validate_arguments(args) @@ -46,12 +46,12 @@ def test_validate_arguments_export_ocsf_requires_provider_uid(): assert "--provider-uid is required" in msg -def test_validate_arguments_export_ocsf_with_provider_uid_passes(): - # --export-ocsf with valid provider_uid should pass +def test_validate_arguments_push_to_cloud_with_provider_uid_passes(): + # --push-to-cloud with valid provider_uid should pass args = Args( scan_path=".", scan_repository_url=None, - export_ocsf=True, + push_to_cloud=True, provider_uid="https://github.com/user/repo.git", ) valid, msg = iac_arguments.validate_arguments(args) @@ -59,19 +59,19 @@ def test_validate_arguments_export_ocsf_with_provider_uid_passes(): assert msg == "" -def test_validate_arguments_no_export_ocsf_without_provider_uid_passes(): - # No --export-ocsf, no provider_uid — should pass +def test_validate_arguments_no_push_to_cloud_without_provider_uid_passes(): + # No --push-to-cloud, no provider_uid — should pass args = Args( scan_path=".", scan_repository_url=None, - export_ocsf=False, + push_to_cloud=False, provider_uid=None, ) valid, msg = iac_arguments.validate_arguments(args) assert valid assert msg == "" - # No export_ocsf attr at all — should pass + # No push_to_cloud attr at all — should pass args = Args(scan_path=".", scan_repository_url=None) valid, msg = iac_arguments.validate_arguments(args) assert valid diff --git a/tests/providers/image/image_provider_test.py b/tests/providers/image/image_provider_test.py index b25e9edfc2..941685f662 100644 --- a/tests/providers/image/image_provider_test.py +++ b/tests/providers/image/image_provider_test.py @@ -1,5 +1,6 @@ import os import tempfile +from argparse import Namespace from unittest import mock from unittest.mock import MagicMock, patch @@ -19,6 +20,7 @@ from prowler.providers.image.exceptions.exceptions import ( ImageScanError, ImageTrivyBinaryNotFoundError, ) +from prowler.providers.common.provider import Provider from prowler.providers.image.image_provider import ImageProvider from tests.providers.image.image_fixtures import ( SAMPLE_IMAGE_SHA, @@ -53,7 +55,7 @@ class TestImageProvider: assert provider._type == "image" assert provider.type == "image" assert provider.images == ["alpine:3.18"] - assert provider.scanners == ["vuln", "secret"] + assert provider.scanners == ["vuln", "secret", "misconfig"] assert provider.image_config_scanners == [] assert provider.trivy_severity == [] assert provider.ignore_unfixed is False @@ -143,7 +145,7 @@ class TestImageProvider: assert report.image_sha == "c1aabb73d233" assert report.resource_details == "alpine:3.18 (alpine 3.18.0)" assert report.region == "container" - assert report.check_metadata.Categories == ["vulnerability"] + assert report.check_metadata.Categories == ["vulnerabilities"] assert report.check_metadata.RelatedUrl == "" def test_process_finding_secret(self): @@ -698,8 +700,118 @@ class TestExtractRegistry: class TestIsRegistryUrl: - def test_registry_url_with_namespace(self): - assert ImageProvider._is_registry_url("docker.io/andoniaf") is True + def test_bare_ecr_hostname(self): + assert ImageProvider._is_registry_url( + "714274078102.dkr.ecr.eu-west-1.amazonaws.com" + ) + + def test_bare_hostname_with_port(self): + assert ImageProvider._is_registry_url("myregistry.com:5000") + + def test_bare_ghcr(self): + assert ImageProvider._is_registry_url("ghcr.io") + + def test_registry_with_namespace_only(self): + """Registry URL with a single path segment (no tag) is a registry URL.""" + assert ImageProvider._is_registry_url("ghcr.io/myorg") + + def test_image_reference_not_registry(self): + """Full image reference with repo and tag is not a registry URL.""" + assert not ImageProvider._is_registry_url("ghcr.io/myorg/repo:tag") + + def test_simple_image_name(self): + assert not ImageProvider._is_registry_url("alpine:3.18") + + def test_bare_image_no_tag(self): + assert not ImageProvider._is_registry_url("nginx") + + def test_dockerhub_namespace(self): + assert not ImageProvider._is_registry_url("library/alpine") + + +class TestTestRegistryConnection: + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_registry_connection_success(self, mock_factory): + """Test that a bare hostname triggers registry catalog test.""" + mock_adapter = MagicMock() + mock_adapter.list_repositories.return_value = ["repo1"] + mock_factory.return_value = mock_adapter + + result = ImageProvider.test_connection( + image="714274078102.dkr.ecr.eu-west-1.amazonaws.com", + registry_username="user", + registry_password="pass", + ) + + assert result.is_connected is True + mock_factory.assert_called_once_with( + registry_url="714274078102.dkr.ecr.eu-west-1.amazonaws.com", + username="user", + password="pass", + token=None, + ) + mock_adapter.list_repositories.assert_called_once() + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_registry_connection_auth_failure(self, mock_factory): + """Test that 401 from registry adapter returns auth failure.""" + mock_adapter = MagicMock() + mock_adapter.list_repositories.side_effect = Exception("401 unauthorized") + mock_factory.return_value = mock_adapter + + result = ImageProvider.test_connection( + image="714274078102.dkr.ecr.eu-west-1.amazonaws.com", + ) + + assert result.is_connected is False + assert "Authentication failed" in result.error + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_registry_connection_generic_error(self, mock_factory): + """Test that a generic error from registry adapter returns error message.""" + mock_adapter = MagicMock() + mock_adapter.list_repositories.side_effect = Exception("connection refused") + mock_factory.return_value = mock_adapter + + result = ImageProvider.test_connection( + image="myregistry.example.com", + ) + + assert result.is_connected is False + assert "Failed to connect to registry" in result.error + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + def test_image_reference_uses_registry_adapter(self, mock_factory): + """Test that a full image reference uses registry adapter to verify tag.""" + mock_adapter = MagicMock() + mock_adapter.list_tags.return_value = ["3.18", "latest"] + mock_factory.return_value = mock_adapter + + result = ImageProvider.test_connection(image="alpine:3.18") + + assert result.is_connected is True + mock_adapter.list_tags.assert_called_once() + + +class TestTrivyAuthIntegration: + @patch("subprocess.run") + def test_run_scan_passes_trivy_env_with_credentials(self, mock_subprocess): + """Test that run_scan() passes TRIVY_USERNAME/PASSWORD via env when credentials are set.""" + mock_subprocess.return_value = MagicMock( + returncode=0, stdout=get_sample_trivy_json_output(), stderr="" + ) + provider = _make_provider( + images=["ghcr.io/user/image:tag"], + registry_username="myuser", + registry_password="mypass", + ) + + list(provider.run_scan()) + + call_kwargs = mock_subprocess.call_args + env = call_kwargs.kwargs.get("env") or call_kwargs[1].get("env") + assert env["TRIVY_USERNAME"] == "myuser" + assert env["TRIVY_PASSWORD"] == "mypass" def test_registry_url_ghcr(self): assert ImageProvider._is_registry_url("ghcr.io/org") is True @@ -734,6 +846,16 @@ class TestCleanup: provider.cleanup() provider.cleanup() + def test_cleanup_removes_trivy_cache_dir(self): + """Test that cleanup removes the temporary Trivy cache directory.""" + provider = _make_provider() + cache_dir = provider._trivy_cache_dir + assert os.path.isdir(cache_dir) + + provider.cleanup() + + assert not os.path.isdir(cache_dir) + class TestImageProviderInputValidation: def test_invalid_timeout_format_raises_error(self): @@ -804,6 +926,22 @@ class TestImageProviderInputValidation: with pytest.raises(ImageInvalidConfigScannerError): _make_provider(image_config_scanners=["misconfig", "vuln"]) + @patch("subprocess.run") + def test_trivy_command_includes_cache_dir(self, mock_subprocess): + """Test that Trivy command includes --cache-dir for cache isolation.""" + provider = _make_provider() + mock_subprocess.return_value = MagicMock( + returncode=0, stdout=get_empty_trivy_output(), stderr="" + ) + + for _ in provider._scan_single_image("alpine:3.18"): + pass + + call_args = mock_subprocess.call_args[0][0] + assert "--cache-dir" in call_args + idx = call_args.index("--cache-dir") + assert call_args[idx + 1] == provider._trivy_cache_dir + @patch("subprocess.run") def test_trivy_command_includes_image_config_scanners(self, mock_subprocess): """Test that Trivy command includes --image-config-scanners when set.""" @@ -988,3 +1126,62 @@ class TestScanPerImage: list(provider.scan_per_image()) mock_cleanup.assert_called_once() + + +class TestInitGlobalProviderRegistryEnumeration: + """Regression test: `prowler image --registry` must discover images. + + PR #9985 added registry scan support. PR #10128 accidentally removed + the registry kwargs from the init_global_provider call, so the CLI + parsed --registry but never forwarded it to ImageProvider. The result + was that registry enumeration silently never ran and the provider + raised ImageNoImagesProvidedError. + """ + + @patch("prowler.providers.image.image_provider.create_registry_adapter") + @patch("prowler.providers.common.provider.load_and_validate_config_file") + def test_cli_registry_flag_discovers_images( + self, mock_load_config, mock_adapter_factory + ): + """Verify that `prowler image --registry myregistry.io --image-filter myorg/` + actually discovers and populates images from the registry.""" + mock_load_config.return_value = {} + + adapter = MagicMock() + adapter.list_repositories.return_value = ["myorg/app", "myorg/api", "other/lib"] + adapter.list_tags.side_effect = [["v1.0", "latest"], ["v2.0"], ["v1.0"]] + mock_adapter_factory.return_value = adapter + + arguments = Namespace( + provider="image", + config_file=None, + fixer_config=None, + images=None, + image_list_file=None, + scanners=["vuln"], + image_config_scanners=None, + trivy_severity=None, + ignore_unfixed=False, + timeout="5m", + registry="myregistry.io", + image_filter="^myorg/", + tag_filter=None, + max_images=0, + registry_insecure=False, + registry_list_images=False, + ) + + # Reset the global singleton so init_global_provider doesn't + # short-circuit via the isinstance check. The patch restores the + # original value automatically on exit. + with mock.patch.object(Provider, "_global", None): + Provider.init_global_provider(arguments) + + provider = Provider._global + # Registry enumeration should have discovered images matching the filter + assert "myregistry.io/myorg/app:v1.0" in provider.images + assert "myregistry.io/myorg/app:latest" in provider.images + assert "myregistry.io/myorg/api:v2.0" in provider.images + # The "other/lib" repo should be filtered out by --image-filter + assert not any("other/lib" in img for img in provider.images) + assert len(provider.images) == 3 diff --git a/tests/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled_test.py b/tests/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled_test.py new file mode 100644 index 0000000000..e0f16cb1b8 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_authentication_method_sms_voice_disabled/entra_authentication_method_sms_voice_disabled_test.py @@ -0,0 +1,221 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + AuthenticationMethodConfiguration, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_authentication_method_sms_voice_disabled: + def test_no_configurations(self): + """ + Test when authentication_method_configurations is empty: + The check should return an empty list of findings. + """ + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import ( + entra_authentication_method_sms_voice_disabled, + ) + + entra_client.authentication_method_configurations = {} + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_sms_voice_disabled() + result = check.execute() + + assert len(result) == 0 + + def test_both_disabled(self): + """ + Test when both SMS and Voice are disabled: + The check should return a single PASS finding. + """ + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import ( + entra_authentication_method_sms_voice_disabled, + ) + + entra_client.authentication_method_configurations = { + "Sms": AuthenticationMethodConfiguration( + id="Sms", + state="disabled", + ), + "Voice": AuthenticationMethodConfiguration( + id="Voice", + state="disabled", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_sms_voice_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "SMS and Voice authentication methods are disabled in the tenant." + ) + assert result[0].resource_id == DOMAIN + assert result[0].resource_name == "SMS and Voice Authentication Methods" + assert result[0].location == "global" + + def test_both_enabled(self): + """ + Test when both SMS and Voice are enabled: + The check should return a single FAIL finding. + """ + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import ( + entra_authentication_method_sms_voice_disabled, + ) + + entra_client.authentication_method_configurations = { + "Sms": AuthenticationMethodConfiguration( + id="Sms", + state="enabled", + ), + "Voice": AuthenticationMethodConfiguration( + id="Voice", + state="enabled", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_sms_voice_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SMS and Voice authentication methods are enabled in the tenant." + ) + assert result[0].resource_id == DOMAIN + assert result[0].resource_name == "SMS and Voice Authentication Methods" + assert result[0].location == "global" + + def test_sms_enabled_voice_disabled(self): + """ + Test when SMS is enabled and Voice is disabled: + The check should return a single FAIL finding. + """ + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import ( + entra_authentication_method_sms_voice_disabled, + ) + + entra_client.authentication_method_configurations = { + "Sms": AuthenticationMethodConfiguration( + id="Sms", + state="enabled", + ), + "Voice": AuthenticationMethodConfiguration( + id="Voice", + state="disabled", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_sms_voice_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "SMS authentication method is enabled in the tenant." + ) + assert result[0].resource_id == DOMAIN + assert result[0].resource_name == "SMS and Voice Authentication Methods" + assert result[0].location == "global" + + def test_sms_disabled_voice_enabled(self): + """ + Test when SMS is disabled and Voice is enabled: + The check should return a single FAIL finding. + """ + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_authentication_method_sms_voice_disabled.entra_authentication_method_sms_voice_disabled import ( + entra_authentication_method_sms_voice_disabled, + ) + + entra_client.authentication_method_configurations = { + "Sms": AuthenticationMethodConfiguration( + id="Sms", + state="disabled", + ), + "Voice": AuthenticationMethodConfiguration( + id="Voice", + state="enabled", + ), + } + entra_client.tenant_domain = DOMAIN + + check = entra_authentication_method_sms_voice_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Voice authentication method is enabled in the tenant." + ) + assert result[0].resource_id == DOMAIN + assert result[0].resource_name == "SMS and Voice Authentication Methods" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py b/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py new file mode 100644 index 0000000000..31e7c06a1d --- /dev/null +++ b/tests/providers/m365/services/entra/entra_break_glass_account_fido2_security_key_registered/entra_break_glass_account_fido2_security_key_registered_test.py @@ -0,0 +1,502 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + User, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered" + + +def _make_policy(policy_id, excluded_users=None, excluded_groups=None, state=None): + """Create a ConditionalAccessPolicy for testing.""" + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + return ConditionalAccessPolicy( + id=policy_id, + display_name=f"Policy {policy_id[:8]}", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=excluded_groups or [], + included_users=["All"], + excluded_users=excluded_users or [], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + ), + 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 or ConditionalAccessPolicyState.ENABLED, + ) + + +class Test_entra_break_glass_account_fido2_security_key_registered: + def test_no_conditional_access_policies(self): + """Test MANUAL when there are no Conditional Access policies.""" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + entra_client.conditional_access_policies = {} + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "No enabled Conditional Access policies found" + in result[0].status_extended + ) + assert result[0].resource == {} + assert result[0].resource_name == "Break Glass Accounts" + assert result[0].resource_id == "breakGlassAccounts" + assert result[0].location == "global" + + def test_all_policies_disabled(self): + """Test MANUAL when all Conditional Access policies are disabled.""" + policy_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy( + policy_id, state=ConditionalAccessPolicyState.DISABLED + ), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "No enabled Conditional Access policies found" + in result[0].status_extended + ) + + def test_no_break_glass_accounts_identified(self): + """Test MANUAL when no user is excluded from all CA policies.""" + policy_id_1 = str(uuid4()) + policy_id_2 = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + # User-1 excluded from policy 1, user-2 from policy 2 -- no one excluded from all + entra_client.conditional_access_policies = { + policy_id_1: _make_policy(policy_id_1, excluded_users=["user-1"]), + policy_id_2: _make_policy(policy_id_2, excluded_users=["user-2"]), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "No break glass accounts identified" in result[0].status_extended + + def test_break_glass_user_with_fido2(self): + """Test PASS when break glass account has FIDO2 registered.""" + policy_id_1 = str(uuid4()) + policy_id_2 = str(uuid4()) + bg_user_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + entra_client.conditional_access_policies = { + policy_id_1: _make_policy(policy_id_1, excluded_users=[bg_user_id]), + policy_id_2: _make_policy(policy_id_2, excluded_users=[bg_user_id]), + } + + entra_client.users = { + bg_user_id: User( + id=bg_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[ + "fido2SecurityKey", + "microsoftAuthenticatorPush", + ], + ), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "BreakGlass1" in result[0].status_extended + assert "FIDO2 security key registered" in result[0].status_extended + assert result[0].resource_name == "BreakGlass1" + assert result[0].resource_id == bg_user_id + + def test_break_glass_user_without_fido2(self): + """Test FAIL when break glass account lacks FIDO2.""" + policy_id_1 = str(uuid4()) + policy_id_2 = str(uuid4()) + bg_user_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + entra_client.conditional_access_policies = { + policy_id_1: _make_policy(policy_id_1, excluded_users=[bg_user_id]), + policy_id_2: _make_policy(policy_id_2, excluded_users=[bg_user_id]), + } + + entra_client.users = { + bg_user_id: User( + id=bg_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=["mobilePhone", "email"], + ), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "BreakGlass1" in result[0].status_extended + assert ( + "does not have a FIDO2 security key registered" + in result[0].status_extended + ) + + def test_break_glass_user_with_empty_authentication_methods(self): + """Test FAIL when break glass account has no authentication methods.""" + policy_id = str(uuid4()) + bg_user_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy(policy_id, excluded_users=[bg_user_id]), + } + + entra_client.users = { + bg_user_id: User( + id=bg_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have a FIDO2 security key registered" + in result[0].status_extended + ) + + def test_break_glass_user_with_passkey_device_bound(self): + """Test MANUAL when break glass account has passKeyDeviceBound but not fido2SecurityKey.""" + policy_id_1 = str(uuid4()) + policy_id_2 = str(uuid4()) + bg_user_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + entra_client.conditional_access_policies = { + policy_id_1: _make_policy(policy_id_1, excluded_users=[bg_user_id]), + policy_id_2: _make_policy(policy_id_2, excluded_users=[bg_user_id]), + } + + entra_client.users = { + bg_user_id: User( + id=bg_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=["passKeyDeviceBound"], + ), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "BreakGlass1" in result[0].status_extended + assert "device-bound passkey registered" in result[0].status_extended + assert "cannot be confirmed" in result[0].status_extended + + def test_multiple_break_glass_users_mixed_results(self): + """Test mixed results when one BG user has FIDO2 and another does not.""" + policy_id_1 = str(uuid4()) + policy_id_2 = str(uuid4()) + bg_user_id_1 = str(uuid4()) + bg_user_id_2 = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + entra_client.conditional_access_policies = { + policy_id_1: _make_policy( + policy_id_1, excluded_users=[bg_user_id_1, bg_user_id_2] + ), + policy_id_2: _make_policy( + policy_id_2, excluded_users=[bg_user_id_1, bg_user_id_2] + ), + } + + entra_client.users = { + bg_user_id_1: User( + id=bg_user_id_1, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=["fido2SecurityKey"], + ), + bg_user_id_2: User( + id=bg_user_id_2, + name="BreakGlass2", + on_premises_sync_enabled=False, + authentication_methods=["mobilePhone"], + ), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 2 + statuses = {r.resource_name: r.status for r in result} + assert statuses["BreakGlass1"] == "PASS" + assert statuses["BreakGlass2"] == "FAIL" + + def test_break_glass_user_not_in_users_dict(self): + """Test that a user excluded from all policies but not in users dict is skipped.""" + policy_id = str(uuid4()) + bg_user_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + entra_client.conditional_access_policies = { + policy_id: _make_policy(policy_id, excluded_users=[bg_user_id]), + } + + # User not present in the users dict + entra_client.users = {} + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + assert len(result) == 0 + + def test_disabled_policies_ignored(self): + """Test that disabled policies are not considered for identifying break glass accounts.""" + policy_id_enabled = str(uuid4()) + policy_id_disabled = str(uuid4()) + bg_user_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_break_glass_account_fido2_security_key_registered.entra_break_glass_account_fido2_security_key_registered import ( + entra_break_glass_account_fido2_security_key_registered, + ) + + # User excluded from the enabled policy but not the disabled one + entra_client.conditional_access_policies = { + policy_id_enabled: _make_policy( + policy_id_enabled, excluded_users=[bg_user_id] + ), + policy_id_disabled: _make_policy( + policy_id_disabled, + excluded_users=[], + state=ConditionalAccessPolicyState.DISABLED, + ), + } + + entra_client.users = { + bg_user_id: User( + id=bg_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=["fido2SecurityKey"], + ), + } + + check = entra_break_glass_account_fido2_security_key_registered() + result = check.execute() + + # Only 1 enabled policy and user is excluded from it → break glass user identified + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "BreakGlass1" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile_test.py new file mode 100644 index 0000000000..71856429f8 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_approved_client_app_required_for_mobile/entra_conditional_access_policy_approved_client_app_required_for_mobile_test.py @@ -0,0 +1,1234 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationEnforcedRestrictions, + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + PlatformConditions, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_conditional_access_policy_approved_client_app_required_for_mobile: + def test_entra_no_conditional_access_policies(self): + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + + entra_client.conditional_access_policies = {} + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + ) + 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_entra_policy_disabled(self): + id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + ) + 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_entra_policy_enabled_for_reporting(self): + id = str(uuid4()) + display_name = "Require Approved Apps for Mobile" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' reports the requirement of approved client apps or app protection for mobile devices but does not enforce it." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_entra_policy_enabled(self): + id = str(uuid4()) + display_name = "Require Approved Apps for Mobile" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' requires approved client apps or app protection for mobile devices." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_entra_policy_missing_platform(self): + id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + ) + 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_entra_policy_missing_grant_controls(self): + id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + ) + 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_entra_policy_no_platform_conditions(self): + id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=None, + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + ) + 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_entra_policy_missing_ios_platform(self): + id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + ) + 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_entra_policy_only_approved_app(self): + id = str(uuid4()) + display_name = "Require Approved Client App for Mobile" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' requires approved client apps or app protection for mobile devices." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_entra_policy_only_compliant_app(self): + id = str(uuid4()) + display_name = "Require App Protection for Mobile" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' requires approved client apps or app protection for mobile devices." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_entra_report_only_policy_then_enabled_policy(self): + report_id = str(uuid4()) + enabled_id = str(uuid4()) + report_name = "Report Only Policy" + enabled_name = "Enforced Policy" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + report_id: ConditionalAccessPolicy( + id=report_id, + display_name=report_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.APPROVED_APPLICATION, + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ), + enabled_id: ConditionalAccessPolicy( + id=enabled_id, + display_name=enabled_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{enabled_name}' requires approved client apps or app protection for mobile devices." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[enabled_id].dict() + ) + assert result[0].resource_name == enabled_name + assert result[0].resource_id == enabled_id + assert result[0].location == "global" + + def test_entra_policy_all_platforms_enabled(self): + id = str(uuid4()) + display_name = "Require App Protection for All Platforms" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' requires approved client apps or app protection for mobile devices." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_entra_policy_excludes_ios_platform(self): + id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Exclude iOS", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["all"], + exclude_platforms=["iOS"], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + ) + 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_entra_policy_or_operator_with_extra_control(self): + id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_approved_client_app_required_for_mobile.entra_conditional_access_policy_approved_client_app_required_for_mobile import ( + entra_conditional_access_policy_approved_client_app_required_for_mobile, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="App Protection Or MFA", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + platform_conditions=PlatformConditions( + include_platforms=["android", "iOS"], + exclude_platforms=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.COMPLIANT_APPLICATION, + ConditionalAccessGrantControl.MFA, + ], + operator=GrantControlOperator.OR, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = ( + entra_conditional_access_policy_approved_client_app_required_for_mobile() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires approved client apps or app protection for mobile devices." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk_test.py new file mode 100644 index 0000000000..bf7fc3952a --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_elevated_insider_risk/entra_conditional_access_policy_block_elevated_insider_risk_test.py @@ -0,0 +1,856 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + InsiderRiskLevel, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk" + + +class Test_entra_conditional_access_policy_block_elevated_insider_risk: + def test_no_conditional_access_policies(self): + """Test FAIL when there are no Conditional Access policies.""" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access for users with elevated insider risk." + ) + 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_policy_disabled(self): + """Test FAIL when the only matching policy is disabled.""" + policy_id = str(uuid4()) + display_name = "Block Elevated Insider Risk" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access for users with elevated insider risk." + ) + 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_policy_enabled_for_reporting_only(self): + """Test FAIL when the matching policy is only in report-only mode.""" + policy_id = str(uuid4()) + display_name = "Block Elevated Insider Risk" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} reports blocking all cloud apps for elevated insider risk users but does not enforce it." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_no_insider_risk_levels_adaptive_protection_not_configured(self): + """Test FAIL when policy matches but Adaptive Protection is not configured.""" + policy_id = str(uuid4()) + display_name = "Block All Apps No Insider Risk" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=None, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} is configured to block all cloud apps and Microsoft Purview Adaptive Protection is not providing insider risk signals." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_report_only_adaptive_protection_not_configured(self): + """Test FAIL when policy is report-only and Adaptive Protection is not configured.""" + policy_id = str(uuid4()) + display_name = "Block All Apps Report Only No Purview" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=None, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} is configured in report-only mode to block all cloud apps and Microsoft Purview Adaptive Protection is not providing insider risk signals." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_no_application_conditions(self): + """Test FAIL when the policy has no application conditions.""" + policy_id = str(uuid4()) + display_name = "Policy Without App Conditions" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=None, + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access for users with elevated insider risk." + ) + 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_policy_does_not_target_all_users(self): + """Test FAIL when the policy targets specific users instead of all users.""" + policy_id = str(uuid4()) + display_name = "Block Insider Risk - Specific Users" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=[str(uuid4())], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access for users with elevated insider risk." + ) + 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_policy_does_not_target_all_apps(self): + """Test FAIL when the policy targets specific apps instead of all cloud apps.""" + policy_id = str(uuid4()) + display_name = "Block Insider Risk - Specific Apps" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access for users with elevated insider risk." + ) + 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_policy_no_block_grant_control(self): + """Test FAIL when the policy does not have block as a grant control.""" + policy_id = str(uuid4()) + display_name = "Insider Risk - MFA Only" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access for users with elevated insider risk." + ) + 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_policy_only_minor_insider_risk(self): + """Test FAIL when the policy only targets minor insider risk, not elevated.""" + policy_id = str(uuid4()) + display_name = "Block Minor Insider Risk Only" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.MINOR, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks access for users with elevated insider risk." + ) + 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_policy_enabled_and_compliant(self): + """Test PASS when an enabled policy blocks all cloud apps for elevated insider risk.""" + policy_id = str(uuid4()) + display_name = "Block Elevated Insider Risk" + 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_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_elevated_insider_risk.entra_conditional_access_policy_block_elevated_insider_risk import ( + entra_conditional_access_policy_block_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_elevated_insider_risk() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} blocks access to all cloud apps for users with elevated insider risk." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk_test.py new file mode 100644 index 0000000000..bec1f76768 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_block_o365_elevated_insider_risk/entra_conditional_access_policy_block_o365_elevated_insider_risk_test.py @@ -0,0 +1,981 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + InsiderRiskLevel, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_conditional_access_policy_block_o365_elevated_insider_risk: + def test_no_conditional_access_policies(self): + """Test FAIL when no conditional access policies exist.""" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks Office 365 access for users with elevated insider risk." + ) + 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_policy_disabled(self): + """Test FAIL when matching policy is disabled.""" + id = str(uuid4()) + display_name = "Block Insider Risk O365" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks Office 365 access for users with elevated insider risk." + ) + 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_policy_enabled_for_reporting(self): + """Test FAIL when policy is in report-only mode.""" + id = str(uuid4()) + display_name = "Block Insider Risk O365 Reporting" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} reports blocking Office 365 for elevated insider risk users but does not enforce it." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_policy_no_block_control(self): + """Test FAIL when policy has insider risk but does not block.""" + id = str(uuid4()) + display_name = "Insider Risk MFA Only" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks Office 365 access for users with elevated insider risk." + ) + 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_policy_no_insider_risk_levels_adaptive_protection_not_configured(self): + """Test FAIL when policy matches but Adaptive Protection is not configured (insider_risk_levels is None).""" + id = str(uuid4()) + display_name = "Block O365 No Insider Risk" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=None, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} is configured to block Office 365 and Microsoft Purview Adaptive Protection is not providing insider risk signals." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_policy_report_only_adaptive_protection_not_configured(self): + """Test FAIL when policy is report-only and Adaptive Protection is not configured.""" + id = str(uuid4()) + display_name = "Block O365 Report Only No Purview" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=None, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} is configured in report-only mode to block Office 365 and Microsoft Purview Adaptive Protection is not providing insider risk signals." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_policy_not_targeting_all_users(self): + """Test FAIL when policy does not target all users.""" + id = str(uuid4()) + display_name = "Block Insider Risk Limited Users" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[str(uuid4())], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks Office 365 access for users with elevated insider risk." + ) + 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_policy_not_targeting_o365(self): + """Test FAIL when policy does not target Office 365 applications.""" + id = str(uuid4()) + display_name = "Block Insider Risk Other Apps" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["some-other-app-id"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks Office 365 access for users with elevated insider risk." + ) + 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_policy_minor_insider_risk(self): + """Test FAIL when policy only targets minor insider risk instead of elevated.""" + id = str(uuid4()) + display_name = "Block Minor Insider Risk O365" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.MINOR, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks Office 365 access for users with elevated insider risk." + ) + 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_policy_enabled_pass(self): + """Test PASS when policy is enabled and blocks O365 for elevated insider risk.""" + id = str(uuid4()) + display_name = "Block Insider Risk O365" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} blocks Office 365 access for users with elevated insider risk." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" + + def test_mixed_policies_report_only_and_enabled_pass(self): + """Test PASS when both a report-only and an enabled policy exist.""" + report_only_id = str(uuid4()) + enabled_id = str(uuid4()) + enabled_display_name = "Block Insider Risk O365 Enabled" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + report_only_id: ConditionalAccessPolicy( + id=report_only_id, + display_name="Block Insider Risk O365 Report Only", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ), + enabled_id: ConditionalAccessPolicy( + id=enabled_id, + display_name=enabled_display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["Office365"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {enabled_display_name} blocks Office 365 access for users with elevated insider risk." + ) + assert result[0].resource_name == enabled_display_name + assert result[0].resource_id == enabled_id + assert result[0].location == "global" + + def test_policy_all_apps_pass(self): + """Test PASS when policy targets all apps (which includes Office 365).""" + id = str(uuid4()) + display_name = "Block Insider Risk All Apps" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_block_o365_elevated_insider_risk.entra_conditional_access_policy_block_o365_elevated_insider_risk import ( + entra_conditional_access_policy_block_o365_elevated_insider_risk, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + insider_risk_levels=InsiderRiskLevel.ELEVATED, + ), + grant_controls=GrantControls( + built_in_controls=[ConditionalAccessGrantControl.BLOCK], + 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.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_block_o365_elevated_insider_risk() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} blocks Office 365 access for users with elevated insider risk." + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required_test.py new file mode 100644 index 0000000000..43e4edbb69 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required/entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required_test.py @@ -0,0 +1,430 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + AdminRoles, + ApplicationEnforcedRestrictions, + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + 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_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required" + +DEFAULT_SESSION_CONTROLS = SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions(is_enabled=False), +) + +EMPTY_USER_CONDITIONS = UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[], + excluded_roles=[], +) + +ALL_USER_CONDITIONS = UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], +) + +ADMIN_ROLE_USER_CONDITIONS = UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[AdminRoles.GLOBAL_ADMINISTRATOR.value], + excluded_roles=[], +) + +EMPTY_APP_CONDITIONS = ApplicationsConditions( + included_applications=[], + excluded_applications=[], + included_user_actions=[], +) + +ALL_APP_CONDITIONS = ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], +) + +REQUIRED_GRANT_CONTROLS = GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ConditionalAccessGrantControl.DOMAIN_JOINED_DEVICE, + ], + operator=GrantControlOperator.OR, +) + + +class Test_entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required: + def test_entra_no_conditional_access_policies(self): + 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_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + + entra_client.conditional_access_policies = {} + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires compliant device, hybrid joined device, or MFA for admin roles or all users across all cloud apps." + ) + + def test_entra_policy_not_targeting_admins_or_all_users(self): + policy_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="No Admins or All Users", + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=EMPTY_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_entra_policy_not_targeting_all_apps(self): + policy_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="Not All Apps", + conditions=Conditions( + application_conditions=EMPTY_APP_CONDITIONS, + user_conditions=ALL_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_entra_policy_missing_required_controls(self): + policy_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="Missing Hybrid Joined", + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=ALL_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ], + operator=GrantControlOperator.OR, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_entra_policy_operator_not_or(self): + policy_id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name="AND Operator", + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=ALL_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ConditionalAccessGrantControl.DOMAIN_JOINED_DEVICE, + ], + operator=GrantControlOperator.AND, + ), + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_entra_policy_reporting_only(self): + policy_id = str(uuid4()) + display_name = "Report Only" + 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_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=ADMIN_ROLE_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} reports compliant device, hybrid joined device, or MFA for admin roles or all users but does not enforce it." + ) + + def test_entra_policy_enabled_pass_for_all_users(self): + policy_id = str(uuid4()) + display_name = "All Users" + 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_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=ALL_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} enforces compliant device, hybrid joined device, or MFA for admin roles or all users across all cloud apps." + ) + + def test_entra_policy_enabled_pass_for_admin_roles(self): + policy_id = str(uuid4()) + display_name = "Admin Roles" + 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_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required.entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required import ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ALL_APP_CONDITIONS, + user_conditions=ADMIN_ROLE_USER_CONDITIONS, + client_app_types=[], + ), + grant_controls=REQUIRED_GRANT_CONTROLS, + session_controls=DEFAULT_SESSION_CONTROLS, + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + result = ( + entra_conditional_access_policy_compliant_device_hybrid_joined_device_mfa_required() + ).execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked_test.py new file mode 100644 index 0000000000..8c0a6a86f8 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_device_code_flow_blocked/entra_conditional_access_policy_device_code_flow_blocked_test.py @@ -0,0 +1,882 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationEnforcedRestrictions, + ApplicationsConditions, + AuthenticationFlows, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + TransferMethod, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_conditional_access_policy_device_code_flow_blocked: + def test_no_conditional_access_policies(self): + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + + entra_client.conditional_access_policies = {} + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_policy_disabled(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_policy_no_authentication_flows(self): + policy_id = str(uuid4()) + display_name = "Block Legacy Auth" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_policy_enabled_for_reporting(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' reports device code flow but does not block it." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_enabled_blocks_device_code_flow(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{display_name}' blocks device code flow." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[policy_id].dict() + ) + assert result[0].resource_name == display_name + assert result[0].resource_id == policy_id + assert result[0].location == "global" + + def test_policy_different_transfer_method(self): + policy_id = str(uuid4()) + display_name = "Block Auth Transfer" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.AUTHENTICATION_TRANSFER] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_multiple_policies_first_disabled_second_enabled(self): + disabled_id = str(uuid4()) + enabled_id = str(uuid4()) + enabled_name = "Block Device Code Flow - Enabled" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + disabled_id: ConditionalAccessPolicy( + id=disabled_id, + display_name="Block Device Code Flow - Disabled", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.DISABLED, + ), + enabled_id: ConditionalAccessPolicy( + id=enabled_id, + display_name=enabled_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ), + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{enabled_name}' blocks device code flow." + ) + assert ( + result[0].resource + == entra_client.conditional_access_policies[enabled_id].dict() + ) + assert result[0].resource_name == enabled_name + assert result[0].resource_id == enabled_id + assert result[0].location == "global" + + def test_policy_not_targeting_all_users(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow - Specific Group" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=["some-group-id"], + excluded_groups=[], + included_users=[], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_policy_not_targeting_all_cloud_apps(self): + policy_id = str(uuid4()) + display_name = "Block Device Code Flow - Specific App" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["some-app-id"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ], + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + 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_policy_with_device_code_flow_but_no_block(self): + policy_id = str(uuid4()) + display_name = "MFA for Device Code Flow" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_code_flow_blocked.entra_conditional_access_policy_device_code_flow_blocked import ( + entra_conditional_access_policy_device_code_flow_blocked, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + policy_id: ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + authentication_flows=AuthenticationFlows( + transfer_methods=[TransferMethod.DEVICE_CODE_FLOW] + ), + ), + grant_controls=GrantControls( + built_in_controls=[ + 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, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_conditional_access_policy_device_code_flow_blocked() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy blocks device code flow." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required_test.py new file mode 100644 index 0000000000..e8060b5ee2 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_device_registration_mfa_required/entra_conditional_access_policy_device_registration_mfa_required_test.py @@ -0,0 +1,276 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + UserAction, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_device_registration_mfa_required.entra_conditional_access_policy_device_registration_mfa_required" + + +def build_policy( + *, + display_name: str, + state: ConditionalAccessPolicyState, + included_users: list[str] | None = None, + included_user_actions: list[UserAction] | None = None, + built_in_controls: list[ConditionalAccessGrantControl] | None = None, +): + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + return ConditionalAccessPolicy( + id=str(uuid4()), + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=[], + excluded_applications=[], + included_user_actions=included_user_actions or [], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ), + grant_controls=GrantControls( + built_in_controls=built_in_controls or [], + 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=None, + ), + ), + state=state, + ) + + +class Test_entra_conditional_access_policy_device_registration_mfa_required: + def test_no_conditional_access_policies(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_registration_mfa_required.entra_conditional_access_policy_device_registration_mfa_required import ( + entra_conditional_access_policy_device_registration_mfa_required, + ) + + entra_client.conditional_access_policies = {} + + result = ( + entra_conditional_access_policy_device_registration_mfa_required().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for device registration." + ) + + def test_enabled_policy_requires_mfa_for_device_registration(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_registration_mfa_required.entra_conditional_access_policy_device_registration_mfa_required import ( + entra_conditional_access_policy_device_registration_mfa_required, + ) + + policy = build_policy( + display_name="Device registration MFA", + state=ConditionalAccessPolicyState.ENABLED, + included_user_actions=[UserAction.REGISTER_DEVICE], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_device_registration_mfa_required().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Conditional Access Policy 'Device registration MFA' enforces MFA for device registration." + ) + assert result[0].resource_id == policy.id + + def test_reporting_only_policy_fails(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_registration_mfa_required.entra_conditional_access_policy_device_registration_mfa_required import ( + entra_conditional_access_policy_device_registration_mfa_required, + ) + + policy = build_policy( + display_name="Device registration MFA", + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_user_actions=[UserAction.REGISTER_DEVICE], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_device_registration_mfa_required().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Conditional Access Policy 'Device registration MFA' reports MFA for device registration but does not enforce it." + ) + + def test_policy_not_targeting_all_users_fails(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_registration_mfa_required.entra_conditional_access_policy_device_registration_mfa_required import ( + entra_conditional_access_policy_device_registration_mfa_required, + ) + + policy = build_policy( + display_name="Scoped device registration MFA", + state=ConditionalAccessPolicyState.ENABLED, + included_users=[str(uuid4())], + included_user_actions=[UserAction.REGISTER_DEVICE], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_device_registration_mfa_required().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for device registration." + ) + + def test_disabled_policy_is_skipped(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_registration_mfa_required.entra_conditional_access_policy_device_registration_mfa_required import ( + entra_conditional_access_policy_device_registration_mfa_required, + ) + + policy = build_policy( + display_name="Disabled device registration MFA", + state=ConditionalAccessPolicyState.DISABLED, + included_user_actions=[UserAction.REGISTER_DEVICE], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_device_registration_mfa_required().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for device registration." + ) + + def test_policy_without_mfa_grant_control_fails(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_device_registration_mfa_required.entra_conditional_access_policy_device_registration_mfa_required import ( + entra_conditional_access_policy_device_registration_mfa_required, + ) + + policy = build_policy( + display_name="Device registration without MFA", + state=ConditionalAccessPolicyState.ENABLED, + included_user_actions=[UserAction.REGISTER_DEVICE], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = ( + entra_conditional_access_policy_device_registration_mfa_required().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires MFA for device registration." + ) diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/__init__.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required_test.py new file mode 100644 index 0000000000..807e59624c --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_mdm_compliant_device_required/entra_conditional_access_policy_mdm_compliant_device_required_test.py @@ -0,0 +1,485 @@ +from types import SimpleNamespace +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, + 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_mdm_compliant_device_required." + "entra_conditional_access_policy_mdm_compliant_device_required" +) + + +def build_policy( + *, + included_users=None, + excluded_users=None, + included_applications=None, + excluded_applications=None, + built_in_controls=None, + operator=GrantControlOperator.OR, + authentication_strength=None, + state=ConditionalAccessPolicyState.ENABLED, + display_name="Test", +): + policy_id = str(uuid4()) + return ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=excluded_applications or [], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=excluded_users or [], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=built_in_controls + or [ConditionalAccessGrantControl.COMPLIANT_DEVICE], + operator=operator, + authentication_strength=authentication_strength, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.TIME_BASED, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=state, + ) + + +def build_intune_client( + *, + verification_error=None, + secure_by_default=True, + assignment_counts=None, + managed_devices=None, +): + assignment_counts = assignment_counts if assignment_counts is not None else [1] + return SimpleNamespace( + verification_error=verification_error, + settings=SimpleNamespace(secure_by_default=secure_by_default), + compliance_policies=[ + SimpleNamespace( + id=str(uuid4()), + display_name=f"Compliance Policy {index}", + assignment_count=assignment_count, + ) + for index, assignment_count in enumerate(assignment_counts, start=1) + ], + managed_devices=( + managed_devices + if managed_devices is not None + else [ + SimpleNamespace( + id=str(uuid4()), + device_name="Managed Device 1", + compliance_state="compliant", + management_agent="mdm", + ) + ] + ), + ) + + +class Test_entra_conditional_access_policy_mdm_compliant_device_required: + def _run_check(self, conditional_access_policies, intune_client): + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + entra_client.conditional_access_policies = conditional_access_policies + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + mock.patch(f"{CHECK_MODULE}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_mdm_compliant_device_required.entra_conditional_access_policy_mdm_compliant_device_required import ( + entra_conditional_access_policy_mdm_compliant_device_required, + ) + + check = entra_conditional_access_policy_mdm_compliant_device_required() + return check.execute() + + def test_no_conditional_access_policies(self): + result = self._run_check({}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + ) + 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_reporting_only_policy_fails(self): + policy = build_policy(state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' reports the requirement of an MDM-compliant device for all cloud app access but does not enforce it." + ) + assert result[0].resource == policy.dict() + assert result[0].resource_name == policy.display_name + assert result[0].resource_id == policy.id + assert result[0].location == "global" + + def test_specific_users_policy_fails(self): + policy = build_policy(included_users=["specific-user-id"]) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + ) + + def test_policy_with_excluded_users_passes(self): + policy = build_policy(excluded_users=["break-glass-id"]) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, and Microsoft Intune is configured with assigned compliance policies, secure-by-default compliance evaluation, and at least one compliant MDM-managed device." + ) + assert result[0].resource == policy.dict() + + def test_policy_with_excluded_applications_fails(self): + policy = build_policy(excluded_applications=["office-app-id"]) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + ) + + def test_policy_with_or_mfa_fails(self): + policy = build_policy( + built_in_controls=[ + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ConditionalAccessGrantControl.MFA, + ], + operator=GrantControlOperator.OR, + ) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + ) + + def test_policy_with_or_authentication_strength_fails(self): + policy = build_policy( + operator=GrantControlOperator.OR, + authentication_strength="Phishing-resistant MFA", + ) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + ) + + def test_intune_verification_error_returns_manual(self): + policy = build_policy() + intune_client = build_intune_client( + verification_error=( + "Could not read Microsoft Intune device management settings. " + "Ensure the Service Principal has DeviceManagementServiceConfig.Read.All permission granted." + ) + ) + + result = self._run_check({policy.id: policy}, intune_client) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune MDM compliance prerequisites could not be verified. {intune_client.verification_error}" + ) + assert result[0].resource == policy.dict() + + def test_no_intune_compliance_policies_fails(self): + policy = build_policy() + intune_client = build_intune_client() + intune_client.compliance_policies = [] + + result = self._run_check({policy.id: policy}, intune_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but no Microsoft Intune device compliance policies are configured." + ) + assert result[0].resource == policy.dict() + + def test_unassigned_intune_compliance_policies_fail(self): + policy = build_policy() + + result = self._run_check( + {policy.id: policy}, + build_intune_client(assignment_counts=[0, 0]), + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but no Microsoft Intune device compliance policy is assigned." + ) + assert result[0].resource == policy.dict() + + def test_secure_by_default_disabled_fails(self): + policy = build_policy() + + result = self._run_check( + {policy.id: policy}, + build_intune_client(secure_by_default=False), + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune allows devices without an assigned compliance policy to remain compliant." + ) + assert result[0].resource == policy.dict() + + def test_missing_secure_by_default_does_not_block_pass(self): + policy = build_policy() + + result = self._run_check( + {policy.id: policy}, + build_intune_client(secure_by_default=None), + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, and Microsoft Intune is configured with assigned compliance policies and at least one compliant MDM-managed device. Microsoft Graph did not return device management settings, so secure-by-default compliance evaluation could not be verified." + ) + assert result[0].resource == policy.dict() + + def test_no_compliant_mdm_managed_devices_fails(self): + policy = build_policy() + + result = self._run_check( + {policy.id: policy}, + build_intune_client(managed_devices=[]), + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune does not currently report any compliant MDM-managed devices." + ) + assert result[0].resource == policy.dict() + + def test_non_mdm_compliant_devices_do_not_pass(self): + policy = build_policy() + + result = self._run_check( + {policy.id: policy}, + build_intune_client( + managed_devices=[ + SimpleNamespace( + id=str(uuid4()), + device_name="EAS Device", + compliance_state="compliant", + management_agent="eas", + ) + ] + ), + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune does not currently report any compliant MDM-managed devices." + ) + assert result[0].resource == policy.dict() + + def test_disabled_policy_is_ignored(self): + """Disabled policy is properly ignored, resulting in generic FAIL.""" + policy = build_policy(state=ConditionalAccessPolicyState.DISABLED) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + + def test_policy_without_compliant_device_grant_is_skipped(self): + """Policy without COMPLIANT_DEVICE grant control is skipped.""" + policy = build_policy( + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + ) + + def test_policy_targeting_specific_apps_is_skipped(self): + """Policy targeting specific apps instead of All is skipped.""" + policy = build_policy(included_applications=["specific-app-id"]) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires an MDM-compliant device for all cloud app access." + ) + + def test_noncompliant_mdm_device_does_not_count(self): + """MDM-managed device with compliance_state='noncompliant' doesn't count as compliant.""" + policy = build_policy() + + result = self._run_check( + {policy.id: policy}, + build_intune_client( + managed_devices=[ + SimpleNamespace( + id=str(uuid4()), + device_name="Noncompliant MDM Device", + compliance_state="noncompliant", + management_agent="mdm", + ) + ] + ), + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, but Microsoft Intune does not currently report any compliant MDM-managed devices." + ) + assert result[0].resource == policy.dict() + + def test_reporting_policy_ignored_when_enabled_policy_exists(self): + """Report-only policy is ignored when a valid enabled policy also exists.""" + reporting_policy = build_policy( + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + display_name="Reporting Policy", + ) + enabled_policy = build_policy( + state=ConditionalAccessPolicyState.ENABLED, + display_name="Enabled Policy", + ) + + result = self._run_check( + {reporting_policy.id: reporting_policy, enabled_policy.id: enabled_policy}, + build_intune_client(), + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "Enabled Policy" in result[0].status_extended + assert result[0].resource == enabled_policy.dict() + + def test_mixed_assigned_unassigned_compliance_policies_pass(self): + """Mixed assigned/unassigned compliance policies (e.g. [0, 1]) still pass.""" + policy = build_policy() + + result = self._run_check( + {policy.id: policy}, + build_intune_client(assignment_counts=[0, 1]), + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource == policy.dict() + + def test_enabled_policy_with_intune_prerequisites_passes(self): + policy = build_policy( + built_in_controls=[ + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ConditionalAccessGrantControl.MFA, + ], + operator=GrantControlOperator.AND, + ) + + result = self._run_check({policy.id: policy}, build_intune_client()) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy '{policy.display_name}' requires an MDM-compliant device for all cloud app access, and Microsoft Intune is configured with assigned compliance policies, secure-by-default compliance evaluation, and at least one compliant MDM-managed device." + ) + assert result[0].resource == policy.dict() + assert result[0].resource_name == policy.display_name + assert result[0].resource_id == policy.id + assert result[0].location == "global" diff --git a/tests/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion_test.py b/tests/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion_test.py index 56dc2ad3c1..db2fd1d193 100644 --- a/tests/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion_test.py +++ b/tests/providers/m365/services/entra/entra_emergency_access_exclusion/entra_emergency_access_exclusion_test.py @@ -8,10 +8,12 @@ from prowler.providers.m365.services.entra.entra_service import ( Conditions, GrantControlOperator, GrantControls, + Group, PersistentBrowser, SessionControls, SignInFrequency, SignInFrequencyInterval, + User, UsersConditions, ) from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider @@ -334,12 +336,23 @@ class Test_entra_emergency_access_exclusion: ), } + entra_client.users = { + emergency_user_id: User( + id=emergency_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + } + entra_client.groups = [] + check = entra_emergency_access_exclusion() result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" + assert "BreakGlass1" in result[0].status_extended assert ( - "1 user(s) excluded as emergency access across all 2 enabled Conditional Access policies" + "excluded from all 2 enabled Conditional Access policies" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" @@ -445,12 +458,23 @@ class Test_entra_emergency_access_exclusion: ), } + entra_client.users = {} + entra_client.groups = [ + Group( + id=emergency_group_id, + name="BreakGlassGroup", + groupTypes=[], + membershipRule=None, + ), + ] + check = entra_emergency_access_exclusion() result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" + assert "BreakGlassGroup" in result[0].status_extended assert ( - "1 group(s) excluded as emergency access across all 2 enabled Conditional Access policies" + "excluded from all 2 enabled Conditional Access policies" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" @@ -557,12 +581,31 @@ class Test_entra_emergency_access_exclusion: ), } + entra_client.users = { + emergency_user_id: User( + id=emergency_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + } + entra_client.groups = [ + Group( + id=emergency_group_id, + name="BreakGlassGroup", + groupTypes=[], + membershipRule=None, + ), + ] + check = entra_emergency_access_exclusion() result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" + assert "BreakGlass1" in result[0].status_extended + assert "BreakGlassGroup" in result[0].status_extended assert ( - "1 user(s) and 1 group(s) excluded as emergency access across all 2 enabled Conditional Access policies" + "excluded from all 2 enabled Conditional Access policies" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" @@ -668,14 +711,25 @@ class Test_entra_emergency_access_exclusion: ), } + entra_client.users = { + emergency_user_id: User( + id=emergency_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + } + entra_client.groups = [] + check = entra_emergency_access_exclusion() result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" assert result[0].resource_name == "Conditional Access Policies" assert result[0].resource_id == "conditionalAccessPolicies" + assert "BreakGlass1" in result[0].status_extended assert ( - "1 user(s) excluded as emergency access across all 1 enabled Conditional Access policies" + "excluded from all 1 enabled Conditional Access policies" in result[0].status_extended ) @@ -780,12 +834,23 @@ class Test_entra_emergency_access_exclusion: ), } + entra_client.users = { + emergency_user_id: User( + id=emergency_user_id, + name="BreakGlass1", + on_premises_sync_enabled=False, + authentication_methods=[], + ), + } + entra_client.groups = [] + check = entra_emergency_access_exclusion() result = check.execute() assert len(result) == 1 assert result[0].status == "PASS" + assert "BreakGlass1" in result[0].status_extended assert ( - "1 user(s) excluded as emergency access across all 2 enabled Conditional Access policies" + "excluded from all 2 enabled Conditional Access policies" in result[0].status_extended ) assert result[0].resource_name == "Conditional Access Policies" diff --git a/tests/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time_test.py b/tests/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time_test.py index 32fca532d2..3cd9adaaf7 100644 --- a/tests/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time_test.py +++ b/tests/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time_test.py @@ -4,6 +4,7 @@ from uuid import uuid4 from prowler.providers.m365.services.entra.entra_service import ( ApplicationEnforcedRestrictions, ApplicationsConditions, + ConditionalAccessGrantControl, ConditionalAccessPolicyState, Conditions, GrantControlOperator, @@ -17,21 +18,85 @@ from prowler.providers.m365.services.entra.entra_service import ( ) from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time" +INTUNE_ENROLLMENT_APP_ID = "d4ebce55-015a-49b5-a083-c84d1797ae8c" +MICROSOFT_INTUNE_APP_ID = "0000000a-0000-0000-c000-000000000000" + + +def build_policy( + *, + display_name: str, + state: ConditionalAccessPolicyState, + included_users: list[str] | None = None, + included_applications: list[str] | None = None, + excluded_applications: list[str] | None = None, + built_in_controls: list[ConditionalAccessGrantControl] | None = None, + operator: GrantControlOperator = GrantControlOperator.OR, + authentication_strength: str | None = None, + sign_in_frequency_enabled: bool = True, + sign_in_frequency_interval: ( + SignInFrequencyInterval | None + ) = SignInFrequencyInterval.EVERY_TIME, +): + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + 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=[], + excluded_groups=[], + included_users=included_users or ["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=built_in_controls or [], + operator=operator, + authentication_strength=authentication_strength, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=sign_in_frequency_enabled, + frequency=None, + type=( + None + if sign_in_frequency_interval == SignInFrequencyInterval.EVERY_TIME + else SignInFrequencyType.HOURS + ), + interval=sign_in_frequency_interval, + ), + application_enforced_restrictions=ApplicationEnforcedRestrictions( + is_enabled=False + ), + ), + state=state, + ) + class Test_entra_intune_enrollment_sign_in_frequency_every_time: - def test_entra_no_conditional_access_policies(self): + def test_no_conditional_access_policies(self): entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN + with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", return_value=set_mocked_m365_provider(), ), - mock.patch( - "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", - new=entra_client, - ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), ): from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( entra_intune_enrollment_sign_in_frequency_every_time, @@ -39,21 +104,16 @@ class Test_entra_intune_enrollment_sign_in_frequency_every_time: entra_client.conditional_access_policies = {} - check = entra_intune_enrollment_sign_in_frequency_every_time() - result = check.execute() + result = entra_intune_enrollment_sign_in_frequency_every_time().execute() + assert len(result) == 1 assert result[0].status == "FAIL" assert ( result[0].status_extended - == "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + == "No Conditional Access Policy requires strong authentication and enforces Every Time sign-in frequency for Intune Enrollment." ) - 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_entra_intune_enrollment_sign_in_frequency_every_time_disabled(self): - id = str(uuid4()) + def test_enabled_policy_requires_mfa_and_every_time(self): entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN @@ -63,76 +123,30 @@ class Test_entra_intune_enrollment_sign_in_frequency_every_time: "prowler.providers.common.provider.Provider.get_global_provider", return_value=set_mocked_m365_provider(), ), - mock.patch( - "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", - new=entra_client, - ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), ): from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( entra_intune_enrollment_sign_in_frequency_every_time, ) - from prowler.providers.m365.services.entra.entra_service import ( - ConditionalAccessPolicy, + + policy = build_policy( + display_name="Intune Enrollment Every Time", + state=ConditionalAccessPolicyState.ENABLED, + included_applications=[INTUNE_ENROLLMENT_APP_ID], + built_in_controls=[ConditionalAccessGrantControl.MFA], ) + entra_client.conditional_access_policies = {policy.id: policy} - entra_client.conditional_access_policies = { - id: ConditionalAccessPolicy( - id=id, - display_name="Test", - conditions=Conditions( - application_conditions=ApplicationsConditions( - included_applications=[ - "d4ebce55-015a-49b5-a083-c84d1797ae8c" - ], # Intune Enrollment - excluded_applications=[], - included_user_actions=[], - ), - user_conditions=UsersConditions( - included_groups=[], - excluded_groups=[], - included_users=["All"], - excluded_users=[], - included_roles=[], - excluded_roles=[], - ), - ), - grant_controls=GrantControls( - built_in_controls=[], operator=GrantControlOperator.AND - ), - 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=ConditionalAccessPolicyState.ENABLED, - ) - } + result = entra_intune_enrollment_sign_in_frequency_every_time().execute() - check = entra_intune_enrollment_sign_in_frequency_every_time() - result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "PASS" assert ( result[0].status_extended - == "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + == "Conditional Access Policy 'Intune Enrollment Every Time' requires strong authentication and enforces Every Time sign-in frequency for Intune Enrollment." ) - 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_entra_intune_sign_in_frequency_every_time_enabled(self): - id = str(uuid4()) - display_name = "Test Intune Enrollment Policy" + def test_enabled_policy_with_authentication_strength_passes(self): entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN @@ -142,76 +156,30 @@ class Test_entra_intune_enrollment_sign_in_frequency_every_time: "prowler.providers.common.provider.Provider.get_global_provider", return_value=set_mocked_m365_provider(), ), - mock.patch( - "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", - new=entra_client, - ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), ): from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( entra_intune_enrollment_sign_in_frequency_every_time, ) - from prowler.providers.m365.services.entra.entra_service import ( - ConditionalAccessPolicy, + + policy = build_policy( + display_name="Intune Enrollment Auth Strength", + state=ConditionalAccessPolicyState.ENABLED, + included_applications=[INTUNE_ENROLLMENT_APP_ID], + authentication_strength="Multifactor authentication", ) + entra_client.conditional_access_policies = {policy.id: policy} - entra_client.conditional_access_policies = { - id: ConditionalAccessPolicy( - id=id, - display_name=display_name, - conditions=Conditions( - application_conditions=ApplicationsConditions( - included_applications=[ - "0000000a-0000-0000-c000-000000000000" - ], # Intune Enrollment - excluded_applications=[], - included_user_actions=[], - ), - user_conditions=UsersConditions( - included_groups=[], - excluded_groups=[], - included_users=["All"], - excluded_users=[], - included_roles=[], - excluded_roles=[], - ), - ), - grant_controls=GrantControls( - built_in_controls=[], operator=GrantControlOperator.AND - ), - session_controls=SessionControls( - persistent_browser=PersistentBrowser( - is_enabled=True, mode="never" - ), - sign_in_frequency=SignInFrequency( - is_enabled=True, - frequency=None, - type=None, - interval=SignInFrequencyInterval.EVERY_TIME, - ), - application_enforced_restrictions=ApplicationEnforcedRestrictions( - is_enabled=False - ), - ), - state=ConditionalAccessPolicyState.ENABLED, - ) - } + result = entra_intune_enrollment_sign_in_frequency_every_time().execute() - check = entra_intune_enrollment_sign_in_frequency_every_time() - result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "PASS" assert ( result[0].status_extended - == "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + == "Conditional Access Policy 'Intune Enrollment Auth Strength' requires strong authentication and enforces Every Time sign-in frequency for Intune Enrollment." ) - 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_entra_all_users_intune_enrollment_4hours(self): - id = str(uuid4()) - display_name = "Test All Users Policy" + def test_policy_without_strong_auth_fails(self): entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN @@ -221,74 +189,29 @@ class Test_entra_intune_enrollment_sign_in_frequency_every_time: "prowler.providers.common.provider.Provider.get_global_provider", return_value=set_mocked_m365_provider(), ), - mock.patch( - "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", - new=entra_client, - ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), ): from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( entra_intune_enrollment_sign_in_frequency_every_time, ) - from prowler.providers.m365.services.entra.entra_service import ( - ConditionalAccessPolicy, + + policy = build_policy( + display_name="Every Time Only", + state=ConditionalAccessPolicyState.ENABLED, + included_applications=[INTUNE_ENROLLMENT_APP_ID], ) + entra_client.conditional_access_policies = {policy.id: policy} - entra_client.conditional_access_policies = { - id: ConditionalAccessPolicy( - id=id, - display_name=display_name, - conditions=Conditions( - application_conditions=ApplicationsConditions( - included_applications=["All"], - excluded_applications=[], - included_user_actions=[], - ), - user_conditions=UsersConditions( - included_groups=[], - excluded_groups=[], - included_users=["All"], - excluded_users=[], - included_roles=[], - excluded_roles=[], - ), - ), - grant_controls=GrantControls( - built_in_controls=[], operator=GrantControlOperator.AND - ), - session_controls=SessionControls( - persistent_browser=PersistentBrowser( - is_enabled=True, mode="never" - ), - sign_in_frequency=SignInFrequency( - is_enabled=True, - frequency=4, - type=SignInFrequencyType.HOURS, - interval=SignInFrequencyInterval.TIME_BASED, - ), - application_enforced_restrictions=ApplicationEnforcedRestrictions( - is_enabled=False - ), - ), - state=ConditionalAccessPolicyState.ENABLED, - ) - } + result = entra_intune_enrollment_sign_in_frequency_every_time().execute() - check = entra_intune_enrollment_sign_in_frequency_every_time() - result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" assert ( result[0].status_extended - == "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + == "No Conditional Access Policy requires strong authentication and enforces Every Time sign-in frequency for Intune Enrollment." ) - 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_entra_intune_enrollment_enabled_for_reporting(self): - id = str(uuid4()) - display_name = "Test Report-Only Policy" + def test_policy_without_every_time_fails(self): entra_client = mock.MagicMock entra_client.audited_tenant = "audited_tenant" entra_client.audited_domain = DOMAIN @@ -298,69 +221,128 @@ class Test_entra_intune_enrollment_sign_in_frequency_every_time: "prowler.providers.common.provider.Provider.get_global_provider", return_value=set_mocked_m365_provider(), ), - mock.patch( - "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", - new=entra_client, - ), + mock.patch(f"{CHECK_MODULE_PATH}.entra_client", new=entra_client), ): from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( entra_intune_enrollment_sign_in_frequency_every_time, ) - from prowler.providers.m365.services.entra.entra_service import ( - ConditionalAccessPolicy, + + policy = build_policy( + display_name="MFA Without Every Time", + state=ConditionalAccessPolicyState.ENABLED, + included_applications=[INTUNE_ENROLLMENT_APP_ID], + built_in_controls=[ConditionalAccessGrantControl.MFA], + sign_in_frequency_interval=SignInFrequencyInterval.TIME_BASED, ) + entra_client.conditional_access_policies = {policy.id: policy} - entra_client.conditional_access_policies = { - id: ConditionalAccessPolicy( - id=id, - display_name=display_name, - conditions=Conditions( - application_conditions=ApplicationsConditions( - included_applications=[ - "d4ebce55-015a-49b5-a083-c84d1797ae8c" - ], # Intune Enrollment - excluded_applications=[], - included_user_actions=[], - ), - user_conditions=UsersConditions( - included_groups=[], - excluded_groups=[], - included_users=["All"], - excluded_users=[], - included_roles=[], - excluded_roles=[], - ), - ), - grant_controls=GrantControls( - built_in_controls=[], operator=GrantControlOperator.AND - ), - session_controls=SessionControls( - persistent_browser=PersistentBrowser( - is_enabled=True, mode="never" - ), - sign_in_frequency=SignInFrequency( - is_enabled=True, - frequency=None, - type=None, - interval=SignInFrequencyInterval.EVERY_TIME, - ), - application_enforced_restrictions=ApplicationEnforcedRestrictions( - is_enabled=False - ), - ), - state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, - ) - } + result = entra_intune_enrollment_sign_in_frequency_every_time().execute() - check = entra_intune_enrollment_sign_in_frequency_every_time() - result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Conditional Access Policy {display_name} reports Every Time sign-in frequency for Intune Enrollment but does not enforce it." + == "No Conditional Access Policy requires strong authentication and enforces Every Time sign-in frequency for Intune Enrollment." + ) + + def test_policy_with_microsoft_intune_app_id_fails(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( + entra_intune_enrollment_sign_in_frequency_every_time, + ) + + policy = build_policy( + display_name="Microsoft Intune Policy", + state=ConditionalAccessPolicyState.ENABLED, + included_applications=[MICROSOFT_INTUNE_APP_ID], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = entra_intune_enrollment_sign_in_frequency_every_time().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires strong authentication and enforces Every Time sign-in frequency for Intune Enrollment." + ) + + def test_policy_with_or_controls_does_not_require_mfa(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( + entra_intune_enrollment_sign_in_frequency_every_time, + ) + + policy = build_policy( + display_name="Intune MFA or managed device", + state=ConditionalAccessPolicyState.ENABLED, + included_applications=[INTUNE_ENROLLMENT_APP_ID], + built_in_controls=[ + ConditionalAccessGrantControl.MFA, + ConditionalAccessGrantControl.DOMAIN_JOINED_DEVICE, + ], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = entra_intune_enrollment_sign_in_frequency_every_time().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy requires strong authentication and enforces Every Time sign-in frequency for Intune Enrollment." + ) + + def test_reporting_only_policy_fails(self): + 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_MODULE_PATH}.entra_client", new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( + entra_intune_enrollment_sign_in_frequency_every_time, + ) + + policy = build_policy( + display_name="Intune Enrollment Report Only", + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_applications=[INTUNE_ENROLLMENT_APP_ID], + built_in_controls=[ConditionalAccessGrantControl.MFA], + ) + entra_client.conditional_access_policies = {policy.id: policy} + + result = entra_intune_enrollment_sign_in_frequency_every_time().execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Conditional Access Policy 'Intune Enrollment Report Only' reports strong authentication and Every Time sign-in frequency for Intune Enrollment but does not enforce them." ) - assert result[0].resource == entra_client.conditional_access_policies[id] - assert result[0].resource_name == display_name - assert result[0].resource_id == id - assert result[0].location == "global" 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 af40a1426f..d83219793e 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -486,8 +486,16 @@ class Test_Entra_Service: registration_details_response = SimpleNamespace( value=[ - SimpleNamespace(id="user-1", is_mfa_capable=True), - SimpleNamespace(id="user-6", is_mfa_capable=True), + SimpleNamespace( + id="user-1", + is_mfa_capable=True, + methods_registered=["fido2SecurityKey"], + ), + SimpleNamespace( + id="user-6", + is_mfa_capable=True, + methods_registered=["mobilePhone"], + ), ], odata_next_link=None, ) @@ -520,19 +528,31 @@ class Test_Entra_Service: assert users["user-6"].account_enabled is False assert users["user-1"].is_mfa_capable is True assert users["user-2"].is_mfa_capable is False + assert users["user-1"].authentication_methods == ["fido2SecurityKey"] + assert users["user-6"].authentication_methods == ["mobilePhone"] + assert users["user-2"].authentication_methods == [] def test__get_user_registration_details_handles_pagination(self): entra_service = Entra.__new__(Entra) registration_response_page_one = SimpleNamespace( value=[ - SimpleNamespace(id="user-1", is_mfa_capable=True), + SimpleNamespace( + id="user-1", + is_mfa_capable=True, + methods_registered=[ + "fido2SecurityKey", + "microsoftAuthenticatorPush", + ], + ), ], odata_next_link="next-link", ) registration_response_page_two = SimpleNamespace( value=[ - SimpleNamespace(id="user-2", is_mfa_capable=False), + SimpleNamespace( + id="user-2", is_mfa_capable=False, methods_registered=[] + ), ], odata_next_link=None, ) @@ -557,7 +577,19 @@ class Test_Entra_Service: entra_service._get_user_registration_details() ) - assert registration_details == {"user-1": True, "user-2": False} + assert registration_details == { + "user-1": { + "is_mfa_capable": True, + "authentication_methods": [ + "fido2SecurityKey", + "microsoftAuthenticatorPush", + ], + }, + "user-2": { + "is_mfa_capable": False, + "authentication_methods": [], + }, + } registration_builder.get.assert_awaited() registration_builder.with_url.assert_called_once_with("next-link") registration_builder_next.get.assert_awaited() diff --git a/tests/providers/m365/services/intune/__init__.py b/tests/providers/m365/services/intune/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/intune/intune_service_test.py b/tests/providers/m365/services/intune/intune_service_test.py new file mode 100644 index 0000000000..3e6762391f --- /dev/null +++ b/tests/providers/m365/services/intune/intune_service_test.py @@ -0,0 +1,369 @@ +import asyncio +from types import SimpleNamespace +from unittest import mock +from unittest.mock import AsyncMock, patch + +from prowler.providers.m365.services.intune.intune_service import ( + Intune, + IntuneCompliancePolicy, + IntuneManagedDevice, + IntuneSettings, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +# --- Mock async helpers for patching Intune methods --- + + +async def mock_get_settings_with_secure_by_default(_): + return IntuneSettings(secure_by_default=True), None + + +async def mock_get_settings_null(_): + return IntuneSettings(secure_by_default=None), None + + +async def mock_get_settings_error(_): + return ( + None, + "Could not read Microsoft Intune device management settings. Ensure the Service Principal has DeviceManagementServiceConfig.Read.All permission granted.", + ) + + +async def mock_get_compliance_policies_with_assignments(_): + return [ + IntuneCompliancePolicy( + id="policy-1", display_name="Windows Policy", assignment_count=2 + ), + IntuneCompliancePolicy( + id="policy-2", display_name="iOS Policy", assignment_count=0 + ), + ], None + + +async def mock_get_compliance_policies_empty(_): + return [], None + + +async def mock_get_compliance_policies_error(_): + return ( + None, + "Could not read Microsoft Intune device compliance policies. Ensure the Service Principal has DeviceManagementConfiguration.Read.All permission granted.", + ) + + +async def mock_get_managed_devices_with_compliant(_): + return [ + IntuneManagedDevice( + id="device-1", + device_name="Laptop-1", + compliance_state="compliant", + management_agent="mdm", + ), + ], None + + +async def mock_get_managed_devices_empty(_): + return [], None + + +async def mock_get_managed_devices_error(_): + return ( + None, + "Could not read Microsoft Intune managed devices. Ensure the Service Principal has DeviceManagementManagedDevices.Read.All permission granted.", + ) + + +def _build_intune_service( + get_settings_mock=mock_get_settings_with_secure_by_default, + get_compliance_policies_mock=mock_get_compliance_policies_with_assignments, + get_managed_devices_mock=mock_get_managed_devices_with_compliant, +): + """Instantiate Intune with patched async methods.""" + with ( + patch( + "prowler.providers.m365.services.intune.intune_service.Intune._get_settings", + new=get_settings_mock, + ), + patch( + "prowler.providers.m365.services.intune.intune_service.Intune._get_compliance_policies", + new=get_compliance_policies_mock, + ), + patch( + "prowler.providers.m365.services.intune.intune_service.Intune._get_managed_devices", + new=get_managed_devices_mock, + ), + ): + return Intune(set_mocked_m365_provider()) + + +class Test_Intune_Service: + def test_get_settings_secure_by_default_true(self): + intune = _build_intune_service() + assert intune.settings is not None + assert intune.settings.secure_by_default is True + assert intune.verification_error is None + + def test_get_settings_null(self): + intune = _build_intune_service( + get_settings_mock=mock_get_settings_null, + ) + assert intune.settings is not None + assert intune.settings.secure_by_default is None + assert intune.verification_error is None + + def test_get_settings_error(self): + intune = _build_intune_service( + get_settings_mock=mock_get_settings_error, + ) + assert intune.settings is None + assert intune.verification_error is not None + assert "DeviceManagementServiceConfig.Read.All" in intune.verification_error + + def test_get_compliance_policies(self): + intune = _build_intune_service() + assert intune.compliance_policies is not None + assert len(intune.compliance_policies) == 2 + assert intune.compliance_policies[0].id == "policy-1" + assert intune.compliance_policies[0].display_name == "Windows Policy" + assert intune.compliance_policies[0].assignment_count == 2 + assert intune.compliance_policies[1].assignment_count == 0 + + def test_get_compliance_policies_empty(self): + intune = _build_intune_service( + get_compliance_policies_mock=mock_get_compliance_policies_empty, + ) + assert intune.compliance_policies == [] + assert intune.verification_error is None + + def test_get_compliance_policies_error(self): + intune = _build_intune_service( + get_compliance_policies_mock=mock_get_compliance_policies_error, + ) + assert intune.compliance_policies is None + assert intune.verification_error is not None + assert "DeviceManagementConfiguration.Read.All" in intune.verification_error + + def test_get_managed_devices(self): + intune = _build_intune_service() + assert intune.managed_devices is not None + assert len(intune.managed_devices) == 1 + assert intune.managed_devices[0].id == "device-1" + assert intune.managed_devices[0].device_name == "Laptop-1" + assert intune.managed_devices[0].compliance_state == "compliant" + assert intune.managed_devices[0].management_agent == "mdm" + + def test_get_managed_devices_empty(self): + intune = _build_intune_service( + get_managed_devices_mock=mock_get_managed_devices_empty, + ) + assert intune.managed_devices == [] + assert intune.verification_error is None + + def test_get_managed_devices_error(self): + intune = _build_intune_service( + get_managed_devices_mock=mock_get_managed_devices_error, + ) + assert intune.managed_devices is None + assert intune.verification_error is not None + assert "DeviceManagementManagedDevices.Read.All" in intune.verification_error + + def test_multiple_errors_concatenated(self): + intune = _build_intune_service( + get_settings_mock=mock_get_settings_error, + get_compliance_policies_mock=mock_get_compliance_policies_error, + ) + assert intune.verification_error is not None + assert "DeviceManagementServiceConfig.Read.All" in intune.verification_error + assert "DeviceManagementConfiguration.Read.All" in intune.verification_error + + def test_is_mdm_managed_device_true(self): + for agent in [ + "mdm", + "easMdm", + "intuneClient", + "easIntuneClient", + "configurationManagerClientMdm", + "configurationManagerClientMdmEas", + "microsoft365ManagedMdm", + ]: + assert Intune.is_mdm_managed_device(agent) is True + + def test_is_mdm_managed_device_false(self): + for agent in ["eas", "googleCloudDevicePolicyController", "", "unknown"]: + assert Intune.is_mdm_managed_device(agent) is False + + +def test_intune_get_compliance_policies_pagination(): + """Test that _get_compliance_policies handles pagination correctly.""" + intune = Intune.__new__(Intune) + + policy_page_one = [ + SimpleNamespace(id="policy-1", display_name="Policy 1"), + ] + policy_page_two = [ + SimpleNamespace(id="policy-2", display_name="Policy 2"), + ] + + response_page_one = SimpleNamespace( + value=policy_page_one, + odata_next_link="next-link", + ) + response_page_two = SimpleNamespace( + value=policy_page_two, + odata_next_link=None, + ) + + assignments_response = SimpleNamespace( + value=[SimpleNamespace()], + odata_next_link=None, + ) + + mock_client = mock.MagicMock() + mock_policies = mock_client.device_management.device_compliance_policies + + mock_policies.get = AsyncMock(return_value=response_page_one) + mock_policies.with_url.return_value.get = AsyncMock(return_value=response_page_two) + mock_policies.by_device_compliance_policy_id.return_value.assignments.get = ( + AsyncMock(return_value=assignments_response) + ) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + policies, error = loop.run_until_complete(intune._get_compliance_policies()) + finally: + loop.close() + + assert error is None + assert len(policies) == 2 + assert policies[0].id == "policy-1" + assert policies[1].id == "policy-2" + assert policies[0].assignment_count == 1 + assert policies[1].assignment_count == 1 + + +def test_intune_get_managed_devices_pagination(): + """Test that _get_managed_devices handles pagination correctly.""" + intune = Intune.__new__(Intune) + + device_page_one = [ + SimpleNamespace( + id="device-1", + device_name="Laptop-1", + compliance_state="compliant", + management_agent="mdm", + ), + ] + device_page_two = [ + SimpleNamespace( + id="device-2", + device_name="Laptop-2", + compliance_state="noncompliant", + management_agent="eas", + ), + ] + + response_page_one = SimpleNamespace( + value=device_page_one, + odata_next_link="next-link", + ) + response_page_two = SimpleNamespace( + value=device_page_two, + odata_next_link=None, + ) + + mock_client = mock.MagicMock() + mock_managed_devices = mock_client.device_management.managed_devices + + mock_managed_devices.get = AsyncMock(return_value=response_page_one) + mock_managed_devices.with_url.return_value.get = AsyncMock( + return_value=response_page_two + ) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + devices, error = loop.run_until_complete(intune._get_managed_devices()) + finally: + loop.close() + + assert error is None + assert len(devices) == 2 + assert devices[0].id == "device-1" + assert devices[0].compliance_state == "compliant" + assert devices[0].management_agent == "mdm" + assert devices[1].id == "device-2" + assert devices[1].compliance_state == "noncompliant" + assert devices[1].management_agent == "eas" + + +def test_intune_get_settings_with_secure_by_default(): + """Test _get_settings when Graph returns settings with secure_by_default.""" + intune = Intune.__new__(Intune) + + device_management_response = SimpleNamespace( + settings=SimpleNamespace(secure_by_default=True) + ) + + mock_client = mock.MagicMock() + mock_client.device_management.get = AsyncMock( + return_value=device_management_response + ) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + settings, error = loop.run_until_complete(intune._get_settings()) + finally: + loop.close() + + assert error is None + assert settings is not None + assert settings.secure_by_default is True + + +def test_intune_get_settings_null_settings(): + """Test _get_settings when Graph returns settings = None.""" + intune = Intune.__new__(Intune) + + device_management_response = SimpleNamespace(settings=None) + + mock_client = mock.MagicMock() + mock_client.device_management.get = AsyncMock( + return_value=device_management_response + ) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + settings, error = loop.run_until_complete(intune._get_settings()) + finally: + loop.close() + + assert error is None + assert settings is not None + assert settings.secure_by_default is None + + +def test_intune_get_settings_exception(): + """Test _get_settings handles exceptions gracefully.""" + intune = Intune.__new__(Intune) + + mock_client = mock.MagicMock() + mock_client.device_management.get = AsyncMock(side_effect=Exception("API Error")) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + settings, error = loop.run_until_complete(intune._get_settings()) + finally: + loop.close() + + assert settings is None + assert error is not None + assert "DeviceManagementServiceConfig.Read.All" in error diff --git a/tests/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared_test.py b/tests/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared_test.py new file mode 100644 index 0000000000..3e2cdd6f9b --- /dev/null +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_acl_not_globally_shared/objectstorage_container_acl_not_globally_shared_test.py @@ -0,0 +1,283 @@ +"""Tests for objectstorage_container_acl_not_globally_shared check.""" + +from unittest import mock + +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorageContainer, +) +from tests.providers.openstack.openstack_fixtures import ( + OPENSTACK_PROJECT_ID, + OPENSTACK_REGION, + set_mocked_openstack_provider, +) + + +class Test_objectstorage_container_acl_not_globally_shared: + """Test suite for objectstorage_container_acl_not_globally_shared check.""" + + def test_no_containers(self): + """Test when no containers exist.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [] + + 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_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared import ( + objectstorage_container_acl_not_globally_shared, + ) + + check = objectstorage_container_acl_not_globally_shared() + result = check.execute() + + assert len(result) == 0 + + def test_container_not_globally_shared(self): + """Test container without global sharing (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-1", + name="project-scoped", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=10, + bytes_used=1024, + read_ACL="project-123:*", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared import ( + objectstorage_container_acl_not_globally_shared, + ) + + check = objectstorage_container_acl_not_globally_shared() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Container project-scoped read ACL is not globally shared." + ) + assert result[0].resource_id == "container-1" + assert result[0].resource_name == "project-scoped" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_globally_shared(self): + """Test container with global sharing (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-2", + name="global-shared", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL="*:*", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared import ( + objectstorage_container_acl_not_globally_shared, + ) + + check = objectstorage_container_acl_not_globally_shared() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Container global-shared has globally shared read ACL (*:*) allowing all authenticated users from any project." + ) + assert result[0].resource_id == "container-2" + assert result[0].resource_name == "global-shared" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_globally_shared_bare_wildcard(self): + """Test container with * (bare wildcard) read ACL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-3", + name="bare-wildcard", + 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={}, + ) + ] + + 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_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared import ( + objectstorage_container_acl_not_globally_shared, + ) + + check = objectstorage_container_acl_not_globally_shared() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_container_star_colon_star_in_multi_entry_acl(self): + """Test container with *:* in multi-entry read ACL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-4", + name="multi-entry-global", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL="project-123:user-456,*:*", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared import ( + objectstorage_container_acl_not_globally_shared, + ) + + check = objectstorage_container_acl_not_globally_shared() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multiple_containers_mixed(self): + """Test multiple containers with mixed ACLs.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-pass", + name="Pass", + 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={}, + ), + ObjectStorageContainer( + id="container-fail", + name="Fail", + 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={}, + ), + ] + + 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_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_acl_not_globally_shared.objectstorage_container_acl_not_globally_shared import ( + objectstorage_container_acl_not_globally_shared, + ) + + check = objectstorage_container_acl_not_globally_shared() + result = check.execute() + + 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 diff --git a/tests/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled_test.py b/tests/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled_test.py new file mode 100644 index 0000000000..73bd1bdaba --- /dev/null +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_listing_disabled/objectstorage_container_listing_disabled_test.py @@ -0,0 +1,334 @@ +"""Tests for objectstorage_container_listing_disabled check.""" + +from unittest import mock + +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorageContainer, +) +from tests.providers.openstack.openstack_fixtures import ( + OPENSTACK_PROJECT_ID, + OPENSTACK_REGION, + set_mocked_openstack_provider, +) + + +class Test_objectstorage_container_listing_disabled: + """Test suite for objectstorage_container_listing_disabled check.""" + + def test_no_containers(self): + """Test when no containers exist.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [] + + 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_listing_disabled.objectstorage_container_listing_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_listing_disabled.objectstorage_container_listing_disabled import ( + objectstorage_container_listing_disabled, + ) + + check = objectstorage_container_listing_disabled() + result = check.execute() + + assert len(result) == 0 + + def test_container_no_listing(self): + """Test container without public listing (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-1", + name="no-listing", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=10, + bytes_used=1024, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_listing_disabled.objectstorage_container_listing_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_listing_disabled.objectstorage_container_listing_disabled import ( + objectstorage_container_listing_disabled, + ) + + check = objectstorage_container_listing_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Container no-listing does not have public listing enabled." + ) + assert result[0].resource_id == "container-1" + assert result[0].resource_name == "no-listing" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_with_listing(self): + """Test container with public listing (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-2", + name="public-listing", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL=".r:*,.rlistings", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_listing_disabled.objectstorage_container_listing_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_listing_disabled.objectstorage_container_listing_disabled import ( + objectstorage_container_listing_disabled, + ) + + check = objectstorage_container_listing_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Container public-listing has public listing enabled (.rlistings) allowing anonymous object enumeration." + ) + assert result[0].resource_id == "container-2" + assert result[0].resource_name == "public-listing" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_listing_via_global_acl_star_colon_star(self): + """Test container with *:* read ACL enabling listing (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-3", + name="global-acl-listing", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL="*:*", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_listing_disabled.objectstorage_container_listing_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_listing_disabled.objectstorage_container_listing_disabled import ( + objectstorage_container_listing_disabled, + ) + + check = objectstorage_container_listing_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Container global-acl-listing has listing enabled via global read ACL (*:*) allowing all authenticated users to list objects." + ) + assert result[0].resource_id == "container-3" + assert result[0].resource_name == "global-acl-listing" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_listing_via_bare_wildcard(self): + """Test container with * read ACL enabling listing (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-4", + name="bare-wildcard-listing", + 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={}, + ) + ] + + 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_listing_disabled.objectstorage_container_listing_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_listing_disabled.objectstorage_container_listing_disabled import ( + objectstorage_container_listing_disabled, + ) + + check = objectstorage_container_listing_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_container_rlistings_takes_priority_over_global(self): + """Test that .rlistings is reported when both .rlistings and *:* are present.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-5", + name="both-patterns", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL=".rlistings,*:*", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_listing_disabled.objectstorage_container_listing_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_listing_disabled.objectstorage_container_listing_disabled import ( + objectstorage_container_listing_disabled, + ) + + check = objectstorage_container_listing_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ".rlistings" in result[0].status_extended + + def test_multiple_containers_mixed(self): + """Test multiple containers with mixed listing status.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-pass", + name="Pass", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL=".r:*", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ), + ObjectStorageContainer( + id="container-fail", + name="Fail", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL=".r:*,.rlistings", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ), + ] + + 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_listing_disabled.objectstorage_container_listing_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_listing_disabled.objectstorage_container_listing_disabled import ( + objectstorage_container_listing_disabled, + ) + + check = objectstorage_container_listing_disabled() + result = check.execute() + + 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 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 new file mode 100644 index 0000000000..6cae6432c7 --- /dev/null +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_metadata_sensitive_data/objectstorage_container_metadata_sensitive_data_test.py @@ -0,0 +1,243 @@ +"""Tests for objectstorage_container_metadata_sensitive_data check.""" + +from unittest import mock + +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorageContainer, +) +from tests.providers.openstack.openstack_fixtures import ( + OPENSTACK_PROJECT_ID, + OPENSTACK_REGION, + set_mocked_openstack_provider, +) + + +class Test_objectstorage_container_metadata_sensitive_data: + """Test suite for objectstorage_container_metadata_sensitive_data check.""" + + def test_no_containers(self): + """Test when no containers exist.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [] + objectstorage_client.audit_config = {} + + 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, + ), + ): + 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) == 0 + + def test_container_no_metadata(self): + """Test container with no metadata (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.audit_config = {} + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-1", + name="no-metadata", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=10, + bytes_used=1024, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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, + ), + ): + 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 == "PASS" + assert ( + result[0].status_extended + == "Container no-metadata has no metadata (no sensitive data exposure risk)." + ) + assert result[0].resource_id == "container-1" + assert result[0].resource_name == "no-metadata" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_safe_metadata(self): + """Test container with safe metadata (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.audit_config = {} + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-2", + name="safe-metadata", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={"environment": "production", "application": "web-app"}, + ) + ] + + 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, + ), + ): + 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 == "PASS" + assert ( + result[0].status_extended + == "Container safe-metadata metadata does not contain sensitive data." + ) + + def test_container_password_in_metadata(self): + """Test container with password in metadata (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.audit_config = {} + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-3", + name="password-metadata", + 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={"db_password": "supersecret123"}, + ) + ] + + 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, + ), + ): + 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 "contains potential secrets" in result[0].status_extended + + def test_multiple_containers_mixed(self): + """Test multiple containers with mixed metadata.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.audit_config = {} + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-pass", + name="Safe", + 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={"tier": "web"}, + ), + ObjectStorageContainer( + id="container-fail", + name="Unsafe", + 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={"admin_password": "secret123"}, + ), + ] + + 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, + ), + ): + 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) == 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 diff --git a/tests/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled_test.py b/tests/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled_test.py new file mode 100644 index 0000000000..a8b484b8ec --- /dev/null +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_public_read_acl_disabled/objectstorage_container_public_read_acl_disabled_test.py @@ -0,0 +1,245 @@ +"""Tests for objectstorage_container_public_read_acl_disabled check.""" + +from unittest import mock + +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorageContainer, +) +from tests.providers.openstack.openstack_fixtures import ( + OPENSTACK_PROJECT_ID, + OPENSTACK_REGION, + set_mocked_openstack_provider, +) + + +class Test_objectstorage_container_public_read_acl_disabled: + """Test suite for objectstorage_container_public_read_acl_disabled check.""" + + def test_no_containers(self): + """Test when no containers exist.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [] + + 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_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled import ( + objectstorage_container_public_read_acl_disabled, + ) + + check = objectstorage_container_public_read_acl_disabled() + result = check.execute() + + assert len(result) == 0 + + def test_container_no_public_read(self): + """Test container without public read ACL (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-1", + name="private-container", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=10, + bytes_used=1024, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled import ( + objectstorage_container_public_read_acl_disabled, + ) + + check = objectstorage_container_public_read_acl_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Container private-container does not have public read ACL." + ) + assert result[0].resource_id == "container-1" + assert result[0].resource_name == "private-container" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_public_read(self): + """Test container with public read ACL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-2", + name="public-container", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL=".r:*,.rlistings", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled import ( + objectstorage_container_public_read_acl_disabled, + ) + + check = objectstorage_container_public_read_acl_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Container public-container has public read ACL (.r:*) allowing anonymous access." + ) + assert result[0].resource_id == "container-2" + assert result[0].resource_name == "public-container" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_domain_restricted_referrer_not_flagged(self): + """Test container with domain-restricted referrer ACL is not flagged (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-3", + name="domain-restricted", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL=".r:*.example.com,.rlistings", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled import ( + objectstorage_container_public_read_acl_disabled, + ) + + check = objectstorage_container_public_read_acl_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Container domain-restricted does not have public read ACL." + ) + + def test_multiple_containers_mixed(self): + """Test multiple containers with mixed ACLs.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-pass", + name="Pass", + 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={}, + ), + ObjectStorageContainer( + id="container-fail", + name="Fail", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL=".r:*", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ), + ] + + 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_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_public_read_acl_disabled.objectstorage_container_public_read_acl_disabled import ( + objectstorage_container_public_read_acl_disabled, + ) + + check = objectstorage_container_public_read_acl_disabled() + result = check.execute() + + 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 diff --git a/tests/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled_test.py b/tests/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled_test.py new file mode 100644 index 0000000000..24e4cdfbec --- /dev/null +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_sync_not_enabled/objectstorage_container_sync_not_enabled_test.py @@ -0,0 +1,199 @@ +"""Tests for objectstorage_container_sync_not_enabled check.""" + +from unittest import mock + +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorageContainer, +) +from tests.providers.openstack.openstack_fixtures import ( + OPENSTACK_PROJECT_ID, + OPENSTACK_REGION, + set_mocked_openstack_provider, +) + + +class Test_objectstorage_container_sync_not_enabled: + """Test suite for objectstorage_container_sync_not_enabled check.""" + + def test_no_containers(self): + """Test when no containers exist.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [] + + 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_sync_not_enabled.objectstorage_container_sync_not_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_sync_not_enabled.objectstorage_container_sync_not_enabled import ( + objectstorage_container_sync_not_enabled, + ) + + check = objectstorage_container_sync_not_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_container_no_sync(self): + """Test container without sync (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-1", + name="no-sync", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=10, + bytes_used=1024, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_sync_not_enabled.objectstorage_container_sync_not_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_sync_not_enabled.objectstorage_container_sync_not_enabled import ( + objectstorage_container_sync_not_enabled, + ) + + check = objectstorage_container_sync_not_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Container no-sync does not have container sync enabled." + ) + assert result[0].resource_id == "container-1" + assert result[0].resource_name == "no-sync" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_with_sync(self): + """Test container with sync enabled (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-2", + name="synced-container", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="https://other-cluster/v1/AUTH_test/container-2", + sync_key="shared-secret", + metadata={}, + ) + ] + + 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_sync_not_enabled.objectstorage_container_sync_not_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_sync_not_enabled.objectstorage_container_sync_not_enabled import ( + objectstorage_container_sync_not_enabled, + ) + + check = objectstorage_container_sync_not_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Container synced-container has container sync enabled (sync target: https://other-cluster/v1/AUTH_test/container-2)." + ) + assert result[0].resource_id == "container-2" + assert result[0].resource_name == "synced-container" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_multiple_containers_mixed(self): + """Test multiple containers with mixed sync status.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-pass", + name="Pass", + 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={}, + ), + ObjectStorageContainer( + id="container-fail", + name="Fail", + 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="https://external/v1/AUTH_test/container", + sync_key="key", + metadata={}, + ), + ] + + 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_sync_not_enabled.objectstorage_container_sync_not_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_sync_not_enabled.objectstorage_container_sync_not_enabled import ( + objectstorage_container_sync_not_enabled, + ) + + check = objectstorage_container_sync_not_enabled() + result = check.execute() + + 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 diff --git a/tests/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled_test.py b/tests/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled_test.py new file mode 100644 index 0000000000..5e66f27c6d --- /dev/null +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_versioning_enabled/objectstorage_container_versioning_enabled_test.py @@ -0,0 +1,249 @@ +"""Tests for objectstorage_container_versioning_enabled check.""" + +from unittest import mock + +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorageContainer, +) +from tests.providers.openstack.openstack_fixtures import ( + OPENSTACK_PROJECT_ID, + OPENSTACK_REGION, + set_mocked_openstack_provider, +) + + +class Test_objectstorage_container_versioning_enabled: + """Test suite for objectstorage_container_versioning_enabled check.""" + + def test_no_containers(self): + """Test when no containers exist.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [] + + 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_versioning_enabled.objectstorage_container_versioning_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_versioning_enabled.objectstorage_container_versioning_enabled import ( + objectstorage_container_versioning_enabled, + ) + + check = objectstorage_container_versioning_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_container_versioning_enabled_versions_location(self): + """Test container with versioning enabled via X-Versions-Location (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-1", + name="versioned-container", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=10, + bytes_used=1024, + read_ACL="", + write_ACL="", + versioning_enabled=True, + versions_location="versioned-container_versions", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_versioning_enabled.objectstorage_container_versioning_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_versioning_enabled.objectstorage_container_versioning_enabled import ( + objectstorage_container_versioning_enabled, + ) + + check = objectstorage_container_versioning_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Container versioned-container has versioning enabled (versions location: versioned-container_versions)." + ) + assert result[0].resource_id == "container-1" + assert result[0].resource_name == "versioned-container" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_versioning_enabled_history_location(self): + """Test container with versioning enabled via X-History-Location (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-1", + name="history-container", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=10, + bytes_used=1024, + read_ACL="", + write_ACL="", + versioning_enabled=True, + versions_location="", + history_location="history-container_versions", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_versioning_enabled.objectstorage_container_versioning_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_versioning_enabled.objectstorage_container_versioning_enabled import ( + objectstorage_container_versioning_enabled, + ) + + check = objectstorage_container_versioning_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Container history-container has versioning enabled (history location: history-container_versions)." + ) + assert result[0].resource_id == "container-1" + assert result[0].resource_name == "history-container" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_versioning_disabled(self): + """Test container without versioning (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-2", + name="no-versioning", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL="", + write_ACL="", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_versioning_enabled.objectstorage_container_versioning_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_versioning_enabled.objectstorage_container_versioning_enabled import ( + objectstorage_container_versioning_enabled, + ) + + check = objectstorage_container_versioning_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Container no-versioning does not have versioning enabled." + ) + assert result[0].resource_id == "container-2" + assert result[0].resource_name == "no-versioning" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_multiple_containers_mixed(self): + """Test multiple containers with mixed versioning status.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-pass", + name="Pass", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL="", + write_ACL="", + versioning_enabled=True, + versions_location="Pass_versions", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ), + ObjectStorageContainer( + id="container-fail", + name="Fail", + 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={}, + ), + ] + + 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_versioning_enabled.objectstorage_container_versioning_enabled.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_versioning_enabled.objectstorage_container_versioning_enabled import ( + objectstorage_container_versioning_enabled, + ) + + check = objectstorage_container_versioning_enabled() + result = check.execute() + + 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 diff --git a/tests/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted_test.py b/tests/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted_test.py new file mode 100644 index 0000000000..dc55902b78 --- /dev/null +++ b/tests/providers/openstack/services/objectstorage/objectstorage_container_write_acl_restricted/objectstorage_container_write_acl_restricted_test.py @@ -0,0 +1,329 @@ +"""Tests for objectstorage_container_write_acl_restricted check.""" + +from unittest import mock + +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorageContainer, +) +from tests.providers.openstack.openstack_fixtures import ( + OPENSTACK_PROJECT_ID, + OPENSTACK_REGION, + set_mocked_openstack_provider, +) + + +class Test_objectstorage_container_write_acl_restricted: + """Test suite for objectstorage_container_write_acl_restricted check.""" + + def test_no_containers(self): + """Test when no containers exist.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [] + + 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_write_acl_restricted.objectstorage_container_write_acl_restricted.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_write_acl_restricted.objectstorage_container_write_acl_restricted import ( + objectstorage_container_write_acl_restricted, + ) + + check = objectstorage_container_write_acl_restricted() + result = check.execute() + + assert len(result) == 0 + + def test_container_restricted_write(self): + """Test container with restricted write ACL (PASS).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-1", + name="restricted-write", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=10, + bytes_used=1024, + read_ACL="", + write_ACL="project-123:user-456", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_write_acl_restricted.objectstorage_container_write_acl_restricted.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_write_acl_restricted.objectstorage_container_write_acl_restricted import ( + objectstorage_container_write_acl_restricted, + ) + + check = objectstorage_container_write_acl_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Container restricted-write has restricted write ACL." + ) + assert result[0].resource_id == "container-1" + assert result[0].resource_name == "restricted-write" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_unrestricted_write_star_colon_star(self): + """Test container with *:* write ACL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-2", + name="unrestricted-write", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=5, + bytes_used=512, + read_ACL="", + write_ACL="*:*", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_write_acl_restricted.objectstorage_container_write_acl_restricted.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_write_acl_restricted.objectstorage_container_write_acl_restricted import ( + objectstorage_container_write_acl_restricted, + ) + + check = objectstorage_container_write_acl_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Container unrestricted-write has unrestricted write ACL allowing all authenticated users to write." + ) + assert result[0].resource_id == "container-2" + assert result[0].resource_name == "unrestricted-write" + assert result[0].region == OPENSTACK_REGION + assert result[0].project_id == OPENSTACK_PROJECT_ID + + def test_container_unrestricted_write_star_only(self): + """Test container with * write ACL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-3", + name="star-write", + 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={}, + ) + ] + + 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_write_acl_restricted.objectstorage_container_write_acl_restricted.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_write_acl_restricted.objectstorage_container_write_acl_restricted import ( + objectstorage_container_write_acl_restricted, + ) + + check = objectstorage_container_write_acl_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_container_star_in_multi_entry_acl(self): + """Test container with * in multi-entry write ACL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-4", + name="star-multi-entry", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL="", + write_ACL="*,project-123:user-456", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_write_acl_restricted.objectstorage_container_write_acl_restricted.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_write_acl_restricted.objectstorage_container_write_acl_restricted import ( + objectstorage_container_write_acl_restricted, + ) + + check = objectstorage_container_write_acl_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Container star-multi-entry has unrestricted write ACL allowing all authenticated users to write." + ) + + def test_container_star_colon_star_in_multi_entry_acl(self): + """Test container with *:* in multi-entry write ACL (FAIL).""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-5", + name="star-colon-multi", + region=OPENSTACK_REGION, + project_id=OPENSTACK_PROJECT_ID, + object_count=0, + bytes_used=0, + read_ACL="", + write_ACL="project-123:user-456,*:*", + versioning_enabled=False, + versions_location="", + history_location="", + sync_to="", + sync_key="", + metadata={}, + ) + ] + + 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_write_acl_restricted.objectstorage_container_write_acl_restricted.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_write_acl_restricted.objectstorage_container_write_acl_restricted import ( + objectstorage_container_write_acl_restricted, + ) + + check = objectstorage_container_write_acl_restricted() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_multiple_containers_mixed(self): + """Test multiple containers with mixed write ACLs.""" + objectstorage_client = mock.MagicMock() + objectstorage_client.containers = [ + ObjectStorageContainer( + id="container-pass", + name="Pass", + 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={}, + ), + ObjectStorageContainer( + id="container-fail", + name="Fail", + 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={}, + ), + ] + + 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_write_acl_restricted.objectstorage_container_write_acl_restricted.objectstorage_client", + new=objectstorage_client, + ), + ): + from prowler.providers.openstack.services.objectstorage.objectstorage_container_write_acl_restricted.objectstorage_container_write_acl_restricted import ( + objectstorage_container_write_acl_restricted, + ) + + check = objectstorage_container_write_acl_restricted() + result = check.execute() + + 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 diff --git a/tests/providers/openstack/services/objectstorage/openstack_objectstorage_service_test.py b/tests/providers/openstack/services/objectstorage/openstack_objectstorage_service_test.py new file mode 100644 index 0000000000..a7e07c97f9 --- /dev/null +++ b/tests/providers/openstack/services/objectstorage/openstack_objectstorage_service_test.py @@ -0,0 +1,403 @@ +"""Tests for OpenStack ObjectStorage service.""" + +from unittest.mock import MagicMock, patch + +from openstack import exceptions as openstack_exceptions + +from prowler.providers.openstack.services.objectstorage.objectstorage_service import ( + ObjectStorage, + ObjectStorageContainer, +) +from tests.providers.openstack.openstack_fixtures import ( + OPENSTACK_PROJECT_ID, + OPENSTACK_REGION, + set_mocked_openstack_provider, +) + + +class TestObjectStorageService: + """Test suite for ObjectStorage service.""" + + def test_objectstorage_service_initialization(self): + """Test ObjectStorage service initializes correctly.""" + provider = set_mocked_openstack_provider() + + with patch.object( + ObjectStorage, "_list_containers", return_value=[] + ) as mock_list: + service = ObjectStorage(provider) + + assert service.service_name == "ObjectStorage" + assert service.provider == provider + assert service.connection == provider.connection + assert service.regional_connections == provider.regional_connections + assert service.audited_regions == [OPENSTACK_REGION] + assert service.region == OPENSTACK_REGION + assert service.project_id == OPENSTACK_PROJECT_ID + assert service.containers == [] + mock_list.assert_called_once() + + def test_objectstorage_list_containers_success(self): + """Test listing containers successfully.""" + provider = set_mocked_openstack_provider() + + mock_container1 = MagicMock() + mock_container1.name = "container-1" + mock_container1.count = 10 + mock_container1.bytes = 1024 + mock_container1.read_ACL = ".r:*,.rlistings" + mock_container1.write_ACL = "*:*" + mock_container1.versions_location = "container-1_versions" + mock_container1.history_location = "" + mock_container1.sync_to = "https://other-cluster/v1/AUTH_test/container-1" + mock_container1.sync_key = "shared-secret" + mock_container1.metadata = {"environment": "production"} + + mock_container2 = MagicMock() + mock_container2.name = "container-2" + mock_container2.count = 0 + mock_container2.bytes = 0 + mock_container2.read_ACL = "" + mock_container2.write_ACL = "" + mock_container2.versions_location = "" + mock_container2.history_location = "" + mock_container2.sync_to = "" + mock_container2.sync_key = "" + mock_container2.metadata = {} + + provider.connection.object_store.containers.return_value = [ + mock_container1, + mock_container2, + ] + + # get_container_metadata returns the detailed mock for each container + def mock_get_metadata(name): + return {"container-1": mock_container1, "container-2": mock_container2}[ + name + ] + + provider.connection.object_store.get_container_metadata.side_effect = ( + mock_get_metadata + ) + + service = ObjectStorage(provider) + + assert len(service.containers) == 2 + assert isinstance(service.containers[0], ObjectStorageContainer) + assert service.containers[0].id == "container-1" + assert service.containers[0].name == "container-1" + assert service.containers[0].region == OPENSTACK_REGION + assert service.containers[0].project_id == OPENSTACK_PROJECT_ID + assert service.containers[0].object_count == 10 + assert service.containers[0].bytes_used == 1024 + assert service.containers[0].read_ACL == ".r:*,.rlistings" + assert service.containers[0].write_ACL == "*:*" + assert service.containers[0].versioning_enabled is True + assert service.containers[0].versions_location == "container-1_versions" + assert service.containers[0].history_location == "" + assert ( + service.containers[0].sync_to + == "https://other-cluster/v1/AUTH_test/container-1" + ) + assert service.containers[0].sync_key == "shared-secret" + assert service.containers[0].metadata == {"environment": "production"} + + assert service.containers[1].id == "container-2" + assert service.containers[1].versioning_enabled is False + assert service.containers[1].sync_to == "" + assert service.containers[1].metadata == {} + + def test_objectstorage_list_containers_empty(self): + """Test listing containers when none exist.""" + provider = set_mocked_openstack_provider() + provider.connection.object_store.containers.return_value = [] + + service = ObjectStorage(provider) + + assert service.containers == [] + + def test_objectstorage_list_containers_missing_attributes(self): + """Test listing containers with missing attributes uses fallback to list data.""" + provider = set_mocked_openstack_provider() + + mock_container = MagicMock() + mock_container.name = "container-1" + del mock_container.count + del mock_container.bytes + del mock_container.read_ACL + del mock_container.write_ACL + del mock_container.versions_location + del mock_container.history_location + del mock_container.sync_to + del mock_container.sync_key + del mock_container.metadata + + provider.connection.object_store.containers.return_value = [mock_container] + + # HEAD also returns missing attributes (same mock) + provider.connection.object_store.get_container_metadata.return_value = ( + mock_container + ) + + service = ObjectStorage(provider) + + assert len(service.containers) == 1 + assert service.containers[0].id == "container-1" + assert service.containers[0].name == "container-1" + assert service.containers[0].object_count == 0 + assert service.containers[0].bytes_used == 0 + assert service.containers[0].read_ACL == "" + assert service.containers[0].write_ACL == "" + assert service.containers[0].versioning_enabled is False + assert service.containers[0].versions_location == "" + assert service.containers[0].history_location == "" + assert service.containers[0].sync_to == "" + assert service.containers[0].sync_key == "" + assert service.containers[0].metadata == {} + + def test_objectstorage_list_containers_head_failure_falls_back(self): + """Test that HEAD failure falls back to list data gracefully.""" + provider = set_mocked_openstack_provider() + + mock_container = MagicMock() + mock_container.name = "container-1" + mock_container.count = 5 + mock_container.bytes = 256 + mock_container.read_ACL = None + mock_container.write_ACL = None + mock_container.versions_location = None + mock_container.history_location = None + mock_container.sync_to = None + mock_container.sync_key = None + mock_container.metadata = {} + + provider.connection.object_store.containers.return_value = [mock_container] + provider.connection.object_store.get_container_metadata.side_effect = Exception( + "HEAD failed" + ) + + service = ObjectStorage(provider) + + # Should still create the container using list data as fallback + assert len(service.containers) == 1 + assert service.containers[0].name == "container-1" + assert service.containers[0].object_count == 5 + assert service.containers[0].bytes_used == 256 + + def test_objectstorage_list_containers_sdk_exception(self): + """Test handling SDKException when listing containers.""" + provider = set_mocked_openstack_provider() + provider.connection.object_store.containers.side_effect = ( + openstack_exceptions.SDKException("API error") + ) + + service = ObjectStorage(provider) + + assert service.containers == [] + + def test_objectstorage_list_containers_generic_exception(self): + """Test handling generic exception when listing containers.""" + provider = set_mocked_openstack_provider() + provider.connection.object_store.containers.side_effect = Exception( + "Unexpected error" + ) + + service = ObjectStorage(provider) + + assert service.containers == [] + + def test_objectstorage_container_dataclass_attributes(self): + """Test ObjectStorageContainer dataclass has all required attributes.""" + container = ObjectStorageContainer( + id="container-1", + name="container-1", + region="RegionOne", + project_id="project-1", + object_count=10, + bytes_used=1024, + read_ACL=".r:*", + write_ACL="*:*", + versioning_enabled=True, + versions_location="container-1_versions", + history_location="", + sync_to="https://other-cluster/v1/AUTH_test/container-1", + sync_key="shared-secret", + metadata={"environment": "production"}, + ) + + assert container.id == "container-1" + assert container.name == "container-1" + assert container.region == "RegionOne" + assert container.project_id == "project-1" + assert container.object_count == 10 + assert container.bytes_used == 1024 + assert container.read_ACL == ".r:*" + assert container.write_ACL == "*:*" + assert container.versioning_enabled is True + assert container.versions_location == "container-1_versions" + assert container.history_location == "" + assert container.sync_to == "https://other-cluster/v1/AUTH_test/container-1" + assert container.sync_key == "shared-secret" + assert container.metadata == {"environment": "production"} + + def test_objectstorage_service_inherits_from_base(self): + """Test ObjectStorage service inherits from OpenStackService.""" + provider = set_mocked_openstack_provider() + + with patch.object(ObjectStorage, "_list_containers", return_value=[]): + service = ObjectStorage(provider) + + assert hasattr(service, "service_name") + assert hasattr(service, "provider") + assert hasattr(service, "connection") + assert hasattr(service, "regional_connections") + assert hasattr(service, "audited_regions") + assert hasattr(service, "session") + assert hasattr(service, "region") + assert hasattr(service, "project_id") + assert hasattr(service, "identity") + assert hasattr(service, "audit_config") + assert hasattr(service, "fixer_config") + + def test_objectstorage_list_containers_multi_region(self): + """Test listing containers across multiple regions.""" + provider = set_mocked_openstack_provider() + + # Create two mock connections for two regions + mock_conn_uk1 = MagicMock() + mock_conn_de1 = MagicMock() + + provider.regional_connections = {"UK1": mock_conn_uk1, "DE1": mock_conn_de1} + + mock_container_uk = MagicMock() + mock_container_uk.name = "container-uk" + mock_container_uk.count = 5 + mock_container_uk.bytes = 512 + mock_container_uk.read_ACL = "" + mock_container_uk.write_ACL = "" + mock_container_uk.versions_location = "" + mock_container_uk.history_location = "" + mock_container_uk.sync_to = "" + mock_container_uk.sync_key = "" + mock_container_uk.metadata = {} + + mock_container_de = MagicMock() + mock_container_de.name = "container-de" + mock_container_de.count = 10 + mock_container_de.bytes = 1024 + mock_container_de.read_ACL = ".r:*" + mock_container_de.write_ACL = "" + mock_container_de.versions_location = "" + mock_container_de.history_location = "" + mock_container_de.sync_to = "" + mock_container_de.sync_key = "" + mock_container_de.metadata = {} + + mock_conn_uk1.object_store.containers.return_value = [mock_container_uk] + mock_conn_uk1.object_store.get_container_metadata.return_value = ( + mock_container_uk + ) + mock_conn_de1.object_store.containers.return_value = [mock_container_de] + mock_conn_de1.object_store.get_container_metadata.return_value = ( + mock_container_de + ) + + service = ObjectStorage(provider) + + assert len(service.containers) == 2 + uk_container = next(c for c in service.containers if c.id == "container-uk") + de_container = next(c for c in service.containers if c.id == "container-de") + assert uk_container.region == "UK1" + assert de_container.region == "DE1" + + def test_objectstorage_list_containers_multi_region_partial_failure(self): + """Test that a failing region doesn't prevent other regions from being listed.""" + provider = set_mocked_openstack_provider() + + mock_conn_ok = MagicMock() + mock_conn_fail = MagicMock() + + provider.regional_connections = {"UK1": mock_conn_ok, "DE1": mock_conn_fail} + + mock_container = MagicMock() + mock_container.name = "container-uk" + mock_container.count = 5 + mock_container.bytes = 512 + mock_container.read_ACL = "" + mock_container.write_ACL = "" + mock_container.versions_location = "" + mock_container.history_location = "" + mock_container.sync_to = "" + mock_container.sync_key = "" + mock_container.metadata = {} + + mock_conn_ok.object_store.containers.return_value = [mock_container] + mock_conn_ok.object_store.get_container_metadata.return_value = mock_container + mock_conn_fail.object_store.containers.side_effect = ( + openstack_exceptions.SDKException("API error in DE1") + ) + + service = ObjectStorage(provider) + + assert len(service.containers) == 1 + assert service.containers[0].id == "container-uk" + assert service.containers[0].region == "UK1" + + def test_objectstorage_list_containers_multi_region_one_empty(self): + """Test multi-region where one region has containers and the other is empty.""" + provider = set_mocked_openstack_provider() + + mock_conn_uk1 = MagicMock() + mock_conn_de1 = MagicMock() + + provider.regional_connections = {"UK1": mock_conn_uk1, "DE1": mock_conn_de1} + + mock_container = MagicMock() + mock_container.name = "container-uk" + mock_container.count = 5 + mock_container.bytes = 512 + mock_container.read_ACL = "" + mock_container.write_ACL = "" + mock_container.versions_location = "" + mock_container.history_location = "" + mock_container.sync_to = "" + mock_container.sync_key = "" + mock_container.metadata = {} + + mock_conn_uk1.object_store.containers.return_value = [mock_container] + mock_conn_uk1.object_store.get_container_metadata.return_value = mock_container + mock_conn_de1.object_store.containers.return_value = [] + + service = ObjectStorage(provider) + + assert len(service.containers) == 1 + assert service.containers[0].id == "container-uk" + assert service.containers[0].region == "UK1" + + def test_objectstorage_list_containers_history_location_versioning(self): + """Test that history_location (X-History-Location) enables versioning.""" + provider = set_mocked_openstack_provider() + + mock_container = MagicMock() + mock_container.name = "history-container" + mock_container.count = 3 + mock_container.bytes = 256 + mock_container.read_ACL = "" + mock_container.write_ACL = "" + mock_container.versions_location = "" + mock_container.history_location = "history-container_versions" + mock_container.sync_to = "" + mock_container.sync_key = "" + mock_container.metadata = {} + + provider.connection.object_store.containers.return_value = [mock_container] + provider.connection.object_store.get_container_metadata.return_value = ( + mock_container + ) + + service = ObjectStorage(provider) + + assert len(service.containers) == 1 + assert service.containers[0].versioning_enabled is True + assert service.containers[0].versions_location == "" + assert service.containers[0].history_location == "history-container_versions" diff --git a/tests/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes_test.py b/tests/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes_test.py index a322bfae58..086b31eba3 100644 --- a/tests/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes_test.py +++ b/tests/providers/oraclecloud/services/events/events_rule_idp_group_mapping_changes/events_rule_idp_group_mapping_changes_test.py @@ -9,6 +9,102 @@ from tests.providers.oraclecloud.oci_fixtures import ( class Test_events_rule_idp_group_mapping_changes: + def test_current_cis_3_1_event_types_pass(self): + """events_rule_idp_group_mapping_changes: current CIS 3.1 event types should pass.""" + events_client = mock.MagicMock() + events_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} + events_client.audited_tenancy = OCI_TENANCY_ID + + rule = mock.MagicMock() + rule.id = "ocid1.eventrule.oc1.iad.aaaaaaaexample" + rule.name = "idp-group-mapping-events" + rule.region = OCI_REGION + rule.compartment_id = OCI_COMPARTMENT_ID + rule.lifecycle_state = "ACTIVE" + rule.is_enabled = True + rule.condition = """ + { + "eventType": [ + "com.oraclecloud.identitycontrolplane.addidpgroupmapping", + "com.oraclecloud.identitycontrolplane.removeidpgroupmapping", + "com.oraclecloud.identitycontrolplane.updateidpgroupmapping" + ] + } + """ + rule.actions = [{"action_type": "ONS", "is_enabled": True}] + + events_client.rules = [rule] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.events.events_rule_idp_group_mapping_changes.events_rule_idp_group_mapping_changes.events_client", + new=events_client, + ), + ): + from prowler.providers.oraclecloud.services.events.events_rule_idp_group_mapping_changes.events_rule_idp_group_mapping_changes import ( + events_rule_idp_group_mapping_changes, + ) + + check = events_rule_idp_group_mapping_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == rule.id + assert result[0].resource_name == rule.name + + def test_legacy_event_types_still_pass(self): + """events_rule_idp_group_mapping_changes: legacy event types remain supported.""" + events_client = mock.MagicMock() + events_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} + events_client.audited_tenancy = OCI_TENANCY_ID + + rule = mock.MagicMock() + rule.id = "ocid1.eventrule.oc1.iad.bbbbbbbexample" + rule.name = "legacy-idp-group-mapping-events" + rule.region = OCI_REGION + rule.compartment_id = OCI_COMPARTMENT_ID + rule.lifecycle_state = "ACTIVE" + rule.is_enabled = True + rule.condition = """ + { + "eventType": [ + "com.oraclecloud.identitycontrolplane.createidpgroupmapping", + "com.oraclecloud.identitycontrolplane.deleteidpgroupmapping", + "com.oraclecloud.identitycontrolplane.updateidpgroupmapping" + ] + } + """ + rule.actions = [{"action_type": "ONS", "is_enabled": True}] + + events_client.rules = [rule] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.events.events_rule_idp_group_mapping_changes.events_rule_idp_group_mapping_changes.events_client", + new=events_client, + ), + ): + from prowler.providers.oraclecloud.services.events.events_rule_idp_group_mapping_changes.events_rule_idp_group_mapping_changes import ( + events_rule_idp_group_mapping_changes, + ) + + check = events_rule_idp_group_mapping_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == rule.id + assert result[0].resource_name == rule.name + def test_no_resources(self): """events_rule_idp_group_mapping_changes: No resources to check""" events_client = mock.MagicMock() diff --git a/tests/providers/oraclecloud/services/identity/identity_password_policy_expires_within_365_days/identity_password_policy_expires_within_365_days_test.py b/tests/providers/oraclecloud/services/identity/identity_password_policy_expires_within_365_days/identity_password_policy_expires_within_365_days_test.py index 189aa115a5..8e1ea7719e 100644 --- a/tests/providers/oraclecloud/services/identity/identity_password_policy_expires_within_365_days/identity_password_policy_expires_within_365_days_test.py +++ b/tests/providers/oraclecloud/services/identity/identity_password_policy_expires_within_365_days/identity_password_policy_expires_within_365_days_test.py @@ -1,5 +1,10 @@ +from datetime import datetime, timezone from unittest import mock +from prowler.providers.oraclecloud.services.identity.identity_service import ( + DomainPasswordPolicy, + IdentityDomain, +) from tests.providers.oraclecloud.oci_fixtures import ( OCI_COMPARTMENT_ID, OCI_REGION, @@ -7,36 +12,34 @@ from tests.providers.oraclecloud.oci_fixtures import ( set_mocked_oraclecloud_provider, ) +DOMAIN_ID = "ocid1.domain.oc1..aaaaaaaexample" +DOMAIN_NAME = "Default" +DOMAIN_URL = "https://idcs-example.identity.oraclecloud.com" +POLICY_ID = "ocid1.passwordpolicy.oc1..aaaaaaaexample" +POLICY_NAME = "CustomPasswordPolicy" + + +def _make_domain(password_policies=None): + return IdentityDomain( + id=DOMAIN_ID, + display_name=DOMAIN_NAME, + description="Default identity domain", + url=DOMAIN_URL, + home_region=OCI_REGION, + compartment_id=OCI_COMPARTMENT_ID, + lifecycle_state="ACTIVE", + time_created=datetime.now(timezone.utc), + region=OCI_REGION, + password_policies=password_policies or [], + ) + class Test_identity_password_policy_expires_within_365_days: - def test_no_resources(self): - """identity_password_policy_expires_within_365_days: No resources to check""" + def test_no_domains(self): + """No Identity Domains → MANUAL finding.""" identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock empty collections - identity_client.rules = [] - identity_client.topics = [] - identity_client.subscriptions = [] - identity_client.users = [] - identity_client.groups = [] - identity_client.policies = [] - identity_client.compartments = [] - identity_client.instances = [] - identity_client.volumes = [] - identity_client.boot_volumes = [] - identity_client.buckets = [] - identity_client.keys = [] - identity_client.file_systems = [] - identity_client.databases = [] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.subnets = [] - identity_client.vcns = [] - identity_client.configuration = None - identity_client.active_non_root_compartments = [] - identity_client.password_policy = None + identity_client.domains = [] with ( mock.patch( @@ -55,134 +58,29 @@ class Test_identity_password_policy_expires_within_365_days: check = identity_password_policy_expires_within_365_days() result = check.execute() - # Verify result is a list (empty or with findings) - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "MANUAL" - def test_resource_compliant(self): - """identity_password_policy_expires_within_365_days: Resource passes the check (PASS)""" + def test_policy_expires_within_365_days(self): + """Password expires within 365 days → PASS.""" identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock a compliant resource - resource = mock.MagicMock() - resource.id = "ocid1.resource.oc1.iad.aaaaaaaexample" - resource.name = "compliant-resource" - resource.region = OCI_REGION - resource.compartment_id = OCI_COMPARTMENT_ID - resource.lifecycle_state = "ACTIVE" - resource.tags = {"Environment": "Production"} - - # Set attributes that make the resource compliant - resource.versioning = "Enabled" - resource.is_auto_rotation_enabled = True - resource.rotation_interval_in_days = 90 - resource.public_access_type = "NoPublicAccess" - resource.logging_enabled = True - resource.kms_key_id = "ocid1.key.oc1.iad.aaaaaaaexample" - resource.in_transit_encryption = "ENABLED" - resource.is_secure_boot_enabled = True - resource.legacy_endpoint_disabled = True - resource.is_legacy_imds_endpoint_disabled = True - - # Mock client with compliant resource - identity_client.buckets = [resource] - identity_client.keys = [resource] - identity_client.volumes = [resource] - identity_client.boot_volumes = [resource] - identity_client.instances = [resource] - identity_client.file_systems = [resource] - identity_client.databases = [resource] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.rules = [] - identity_client.configuration = resource - identity_client.users = [] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_oraclecloud_provider(), - ), - mock.patch( - "prowler.providers.oraclecloud.services.identity.identity_password_policy_expires_within_365_days.identity_password_policy_expires_within_365_days.identity_client", - new=identity_client, - ), - ): - from prowler.providers.oraclecloud.services.identity.identity_password_policy_expires_within_365_days.identity_password_policy_expires_within_365_days import ( - identity_password_policy_expires_within_365_days, - ) - - check = identity_password_policy_expires_within_365_days() - result = check.execute() - - assert isinstance(result, list) - - # If results exist, verify PASS findings - if len(result) > 0: - # Find PASS results - pass_results = [r for r in result if r.status == "PASS"] - - if pass_results: - # Detailed assertions on first PASS result - assert pass_results[0].status == "PASS" - assert pass_results[0].status_extended is not None - assert len(pass_results[0].status_extended) > 0 - - # Verify resource identification - assert pass_results[0].resource_id is not None - assert pass_results[0].resource_name is not None - assert pass_results[0].region is not None - assert pass_results[0].compartment_id is not None - - # Verify metadata - assert pass_results[0].check_metadata.Provider == "oraclecloud" - assert ( - pass_results[0].check_metadata.CheckID - == "identity_password_policy_expires_within_365_days" + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=90, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, ) - assert pass_results[0].check_metadata.ServiceName == "identity" - - def test_resource_non_compliant(self): - """identity_password_policy_expires_within_365_days: Resource fails the check (FAIL)""" - identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} - identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock a non-compliant resource - resource = mock.MagicMock() - resource.id = "ocid1.resource.oc1.iad.bbbbbbbexample" - resource.name = "non-compliant-resource" - resource.region = OCI_REGION - resource.compartment_id = OCI_COMPARTMENT_ID - resource.lifecycle_state = "ACTIVE" - resource.tags = {"Environment": "Development"} - - # Set attributes that make the resource non-compliant - resource.versioning = "Disabled" - resource.is_auto_rotation_enabled = False - resource.rotation_interval_in_days = None - resource.public_access_type = "ObjectRead" - resource.logging_enabled = False - resource.kms_key_id = None - resource.in_transit_encryption = "DISABLED" - resource.is_secure_boot_enabled = False - resource.legacy_endpoint_disabled = False - resource.is_legacy_imds_endpoint_disabled = False - - # Mock client with non-compliant resource - identity_client.buckets = [resource] - identity_client.keys = [resource] - identity_client.volumes = [resource] - identity_client.boot_volumes = [resource] - identity_client.instances = [resource] - identity_client.file_systems = [resource] - identity_client.databases = [resource] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.rules = [] - identity_client.configuration = resource - identity_client.users = [] + ] + ) + ] with ( mock.patch( @@ -201,29 +99,139 @@ class Test_identity_password_policy_expires_within_365_days: check = identity_password_policy_expires_within_365_days() result = check.execute() - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "90 days" in result[0].status_extended + assert result[0].resource_id == POLICY_ID - # Verify FAIL findings exist - if len(result) > 0: - # Find FAIL results - fail_results = [r for r in result if r.status == "FAIL"] - - if fail_results: - # Detailed assertions on first FAIL result - assert fail_results[0].status == "FAIL" - assert fail_results[0].status_extended is not None - assert len(fail_results[0].status_extended) > 0 - - # Verify resource identification - assert fail_results[0].resource_id is not None - assert fail_results[0].resource_name is not None - assert fail_results[0].region is not None - assert fail_results[0].compartment_id is not None - - # Verify metadata - assert fail_results[0].check_metadata.Provider == "oraclecloud" - assert ( - fail_results[0].check_metadata.CheckID - == "identity_password_policy_expires_within_365_days" + def test_policy_expires_over_365_days(self): + """Password expires after more than 365 days → FAIL.""" + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=500, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, ) - assert fail_results[0].check_metadata.ServiceName == "identity" + ] + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_expires_within_365_days.identity_password_policy_expires_within_365_days.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_expires_within_365_days.identity_password_policy_expires_within_365_days import ( + identity_password_policy_expires_within_365_days, + ) + + check = identity_password_policy_expires_within_365_days() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "500 days" in result[0].status_extended + + def test_policy_no_expiration_configured(self): + """No password expiration configured → FAIL.""" + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=None, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, + ) + ] + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_expires_within_365_days.identity_password_policy_expires_within_365_days.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_expires_within_365_days.identity_password_policy_expires_within_365_days import ( + identity_password_policy_expires_within_365_days, + ) + + check = identity_password_policy_expires_within_365_days() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not have password expiration" in result[0].status_extended + + def test_system_managed_policies_excluded(self): + """System-managed policies should not appear in domain.password_policies. + + This is a regression test: SimplePasswordPolicy and StandardPasswordPolicy + are filtered at the service layer, so checks never see them. + """ + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + # Only user-configurable policy in the domain (system ones filtered by service) + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=90, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, + ) + ] + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_expires_within_365_days.identity_password_policy_expires_within_365_days.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_expires_within_365_days.identity_password_policy_expires_within_365_days import ( + identity_password_policy_expires_within_365_days, + ) + + check = identity_password_policy_expires_within_365_days() + result = check.execute() + + # Only 1 finding for the custom policy, none for system-managed + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == POLICY_ID diff --git a/tests/providers/oraclecloud/services/identity/identity_password_policy_minimum_length_14/identity_password_policy_minimum_length_14_test.py b/tests/providers/oraclecloud/services/identity/identity_password_policy_minimum_length_14/identity_password_policy_minimum_length_14_test.py index f624bb3f14..b0165f071c 100644 --- a/tests/providers/oraclecloud/services/identity/identity_password_policy_minimum_length_14/identity_password_policy_minimum_length_14_test.py +++ b/tests/providers/oraclecloud/services/identity/identity_password_policy_minimum_length_14/identity_password_policy_minimum_length_14_test.py @@ -1,5 +1,11 @@ +from datetime import datetime, timezone from unittest import mock +from prowler.providers.oraclecloud.services.identity.identity_service import ( + DomainPasswordPolicy, + IdentityDomain, + PasswordPolicy, +) from tests.providers.oraclecloud.oci_fixtures import ( OCI_COMPARTMENT_ID, OCI_REGION, @@ -7,36 +13,36 @@ from tests.providers.oraclecloud.oci_fixtures import ( set_mocked_oraclecloud_provider, ) +DOMAIN_ID = "ocid1.domain.oc1..aaaaaaaexample" +DOMAIN_NAME = "Default" +DOMAIN_URL = "https://idcs-example.identity.oraclecloud.com" +POLICY_ID = "ocid1.passwordpolicy.oc1..aaaaaaaexample" +POLICY_NAME = "CustomPasswordPolicy" + + +def _make_domain(password_policies=None): + return IdentityDomain( + id=DOMAIN_ID, + display_name=DOMAIN_NAME, + description="Default identity domain", + url=DOMAIN_URL, + home_region=OCI_REGION, + compartment_id=OCI_COMPARTMENT_ID, + lifecycle_state="ACTIVE", + time_created=datetime.now(timezone.utc), + region=OCI_REGION, + password_policies=password_policies or [], + ) + class Test_identity_password_policy_minimum_length_14: - def test_no_resources(self): - """identity_password_policy_minimum_length_14: No resources to check""" + def test_no_domains_no_legacy_policy(self): + """No domains and no legacy policy → FAIL.""" identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock empty collections - identity_client.rules = [] - identity_client.topics = [] - identity_client.subscriptions = [] - identity_client.users = [] - identity_client.groups = [] - identity_client.policies = [] - identity_client.compartments = [] - identity_client.instances = [] - identity_client.volumes = [] - identity_client.boot_volumes = [] - identity_client.buckets = [] - identity_client.keys = [] - identity_client.file_systems = [] - identity_client.databases = [] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.subnets = [] - identity_client.vcns = [] - identity_client.configuration = None - identity_client.active_non_root_compartments = [] + identity_client.domains = [] identity_client.password_policy = None + identity_client.provider.identity.region = OCI_REGION with ( mock.patch( @@ -55,49 +61,30 @@ class Test_identity_password_policy_minimum_length_14: check = identity_password_policy_minimum_length_14() result = check.execute() - # Verify result is a list (empty or with findings) - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "No password policy" in result[0].status_extended - def test_resource_compliant(self): - """identity_password_policy_minimum_length_14: Resource passes the check (PASS)""" + def test_domain_policy_min_length_14(self): + """Domain password policy with min_length >= 14 → PASS.""" identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock a compliant resource - resource = mock.MagicMock() - resource.id = "ocid1.resource.oc1.iad.aaaaaaaexample" - resource.name = "compliant-resource" - resource.region = OCI_REGION - resource.compartment_id = OCI_COMPARTMENT_ID - resource.lifecycle_state = "ACTIVE" - resource.tags = {"Environment": "Production"} - - # Set attributes that make the resource compliant - resource.versioning = "Enabled" - resource.is_auto_rotation_enabled = True - resource.rotation_interval_in_days = 90 - resource.public_access_type = "NoPublicAccess" - resource.logging_enabled = True - resource.kms_key_id = "ocid1.key.oc1.iad.aaaaaaaexample" - resource.in_transit_encryption = "ENABLED" - resource.is_secure_boot_enabled = True - resource.legacy_endpoint_disabled = True - resource.is_legacy_imds_endpoint_disabled = True - - # Mock client with compliant resource - identity_client.buckets = [resource] - identity_client.keys = [resource] - identity_client.volumes = [resource] - identity_client.boot_volumes = [resource] - identity_client.instances = [resource] - identity_client.file_systems = [resource] - identity_client.databases = [resource] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.rules = [] - identity_client.configuration = resource - identity_client.users = [] + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=90, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, + ) + ] + ) + ] with ( mock.patch( @@ -116,73 +103,31 @@ class Test_identity_password_policy_minimum_length_14: check = identity_password_policy_minimum_length_14() result = check.execute() - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "14 characters" in result[0].status_extended + assert result[0].resource_id == POLICY_ID - # If results exist, verify PASS findings - if len(result) > 0: - # Find PASS results - pass_results = [r for r in result if r.status == "PASS"] - - if pass_results: - # Detailed assertions on first PASS result - assert pass_results[0].status == "PASS" - assert pass_results[0].status_extended is not None - assert len(pass_results[0].status_extended) > 0 - - # Verify resource identification - assert pass_results[0].resource_id is not None - assert pass_results[0].resource_name is not None - assert pass_results[0].region is not None - assert pass_results[0].compartment_id is not None - - # Verify metadata - assert pass_results[0].check_metadata.Provider == "oraclecloud" - assert ( - pass_results[0].check_metadata.CheckID - == "identity_password_policy_minimum_length_14" - ) - assert pass_results[0].check_metadata.ServiceName == "identity" - - def test_resource_non_compliant(self): - """identity_password_policy_minimum_length_14: Resource fails the check (FAIL)""" + def test_domain_policy_min_length_too_short(self): + """Domain password policy with min_length < 14 → FAIL.""" identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock a non-compliant resource - resource = mock.MagicMock() - resource.id = "ocid1.resource.oc1.iad.bbbbbbbexample" - resource.name = "non-compliant-resource" - resource.region = OCI_REGION - resource.compartment_id = OCI_COMPARTMENT_ID - resource.lifecycle_state = "ACTIVE" - resource.tags = {"Environment": "Development"} - - # Set attributes that make the resource non-compliant - resource.versioning = "Disabled" - resource.is_auto_rotation_enabled = False - resource.rotation_interval_in_days = None - resource.public_access_type = "ObjectRead" - resource.logging_enabled = False - resource.kms_key_id = None - resource.in_transit_encryption = "DISABLED" - resource.is_secure_boot_enabled = False - resource.legacy_endpoint_disabled = False - resource.is_legacy_imds_endpoint_disabled = False - - # Mock client with non-compliant resource - identity_client.buckets = [resource] - identity_client.keys = [resource] - identity_client.volumes = [resource] - identity_client.boot_volumes = [resource] - identity_client.instances = [resource] - identity_client.file_systems = [resource] - identity_client.databases = [resource] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.rules = [] - identity_client.configuration = resource - identity_client.users = [] + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=8, + password_expires_after=90, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, + ) + ] + ) + ] with ( mock.patch( @@ -201,29 +146,116 @@ class Test_identity_password_policy_minimum_length_14: check = identity_password_policy_minimum_length_14() result = check.execute() - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "8" in result[0].status_extended - # Verify FAIL findings exist - if len(result) > 0: - # Find FAIL results - fail_results = [r for r in result if r.status == "FAIL"] + def test_legacy_policy_compliant(self): + """Legacy password policy with min length >= 14 → PASS.""" + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [] + identity_client.password_policy = PasswordPolicy( + is_lowercase_characters_required=True, + is_uppercase_characters_required=True, + is_numeric_characters_required=True, + is_special_characters_required=True, + is_username_containment_allowed=False, + minimum_password_length=14, + ) + identity_client.provider.identity.region = OCI_REGION - if fail_results: - # Detailed assertions on first FAIL result - assert fail_results[0].status == "FAIL" - assert fail_results[0].status_extended is not None - assert len(fail_results[0].status_extended) > 0 + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_minimum_length_14.identity_password_policy_minimum_length_14.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_minimum_length_14.identity_password_policy_minimum_length_14 import ( + identity_password_policy_minimum_length_14, + ) - # Verify resource identification - assert fail_results[0].resource_id is not None - assert fail_results[0].resource_name is not None - assert fail_results[0].region is not None - assert fail_results[0].compartment_id is not None + check = identity_password_policy_minimum_length_14() + result = check.execute() - # Verify metadata - assert fail_results[0].check_metadata.Provider == "oraclecloud" - assert ( - fail_results[0].check_metadata.CheckID - == "identity_password_policy_minimum_length_14" + assert len(result) == 1 + assert result[0].status == "PASS" + assert "14 characters" in result[0].status_extended + + def test_domain_no_policies(self): + """Domain with no password policies → FAIL.""" + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [_make_domain([])] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_minimum_length_14.identity_password_policy_minimum_length_14.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_minimum_length_14.identity_password_policy_minimum_length_14 import ( + identity_password_policy_minimum_length_14, + ) + + check = identity_password_policy_minimum_length_14() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no password policy configured" in result[0].status_extended + + def test_system_managed_policies_excluded(self): + """System-managed policies should not appear in domain.password_policies. + + This is a regression test: SimplePasswordPolicy and StandardPasswordPolicy + are filtered at the service layer, so checks never see them. + """ + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=90, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, ) - assert fail_results[0].check_metadata.ServiceName == "identity" + ] + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_minimum_length_14.identity_password_policy_minimum_length_14.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_minimum_length_14.identity_password_policy_minimum_length_14 import ( + identity_password_policy_minimum_length_14, + ) + + check = identity_password_policy_minimum_length_14() + result = check.execute() + + # Only 1 finding for the custom policy, none for system-managed + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == POLICY_ID diff --git a/tests/providers/oraclecloud/services/identity/identity_password_policy_prevents_reuse/identity_password_policy_prevents_reuse_test.py b/tests/providers/oraclecloud/services/identity/identity_password_policy_prevents_reuse/identity_password_policy_prevents_reuse_test.py index 07a168ab4c..4754f9c0c4 100644 --- a/tests/providers/oraclecloud/services/identity/identity_password_policy_prevents_reuse/identity_password_policy_prevents_reuse_test.py +++ b/tests/providers/oraclecloud/services/identity/identity_password_policy_prevents_reuse/identity_password_policy_prevents_reuse_test.py @@ -1,5 +1,10 @@ +from datetime import datetime, timezone from unittest import mock +from prowler.providers.oraclecloud.services.identity.identity_service import ( + DomainPasswordPolicy, + IdentityDomain, +) from tests.providers.oraclecloud.oci_fixtures import ( OCI_COMPARTMENT_ID, OCI_REGION, @@ -7,36 +12,34 @@ from tests.providers.oraclecloud.oci_fixtures import ( set_mocked_oraclecloud_provider, ) +DOMAIN_ID = "ocid1.domain.oc1..aaaaaaaexample" +DOMAIN_NAME = "Default" +DOMAIN_URL = "https://idcs-example.identity.oraclecloud.com" +POLICY_ID = "ocid1.passwordpolicy.oc1..aaaaaaaexample" +POLICY_NAME = "CustomPasswordPolicy" + + +def _make_domain(password_policies=None): + return IdentityDomain( + id=DOMAIN_ID, + display_name=DOMAIN_NAME, + description="Default identity domain", + url=DOMAIN_URL, + home_region=OCI_REGION, + compartment_id=OCI_COMPARTMENT_ID, + lifecycle_state="ACTIVE", + time_created=datetime.now(timezone.utc), + region=OCI_REGION, + password_policies=password_policies or [], + ) + class Test_identity_password_policy_prevents_reuse: - def test_no_resources(self): - """identity_password_policy_prevents_reuse: No resources to check""" + def test_no_domains(self): + """No Identity Domains → MANUAL finding.""" identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock empty collections - identity_client.rules = [] - identity_client.topics = [] - identity_client.subscriptions = [] - identity_client.users = [] - identity_client.groups = [] - identity_client.policies = [] - identity_client.compartments = [] - identity_client.instances = [] - identity_client.volumes = [] - identity_client.boot_volumes = [] - identity_client.buckets = [] - identity_client.keys = [] - identity_client.file_systems = [] - identity_client.databases = [] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.subnets = [] - identity_client.vcns = [] - identity_client.configuration = None - identity_client.active_non_root_compartments = [] - identity_client.password_policy = None + identity_client.domains = [] with ( mock.patch( @@ -55,134 +58,29 @@ class Test_identity_password_policy_prevents_reuse: check = identity_password_policy_prevents_reuse() result = check.execute() - # Verify result is a list (empty or with findings) - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "MANUAL" - def test_resource_compliant(self): - """identity_password_policy_prevents_reuse: Resource passes the check (PASS)""" + def test_policy_prevents_reuse_24(self): + """Password history >= 24 → PASS.""" identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock a compliant resource - resource = mock.MagicMock() - resource.id = "ocid1.resource.oc1.iad.aaaaaaaexample" - resource.name = "compliant-resource" - resource.region = OCI_REGION - resource.compartment_id = OCI_COMPARTMENT_ID - resource.lifecycle_state = "ACTIVE" - resource.tags = {"Environment": "Production"} - - # Set attributes that make the resource compliant - resource.versioning = "Enabled" - resource.is_auto_rotation_enabled = True - resource.rotation_interval_in_days = 90 - resource.public_access_type = "NoPublicAccess" - resource.logging_enabled = True - resource.kms_key_id = "ocid1.key.oc1.iad.aaaaaaaexample" - resource.in_transit_encryption = "ENABLED" - resource.is_secure_boot_enabled = True - resource.legacy_endpoint_disabled = True - resource.is_legacy_imds_endpoint_disabled = True - - # Mock client with compliant resource - identity_client.buckets = [resource] - identity_client.keys = [resource] - identity_client.volumes = [resource] - identity_client.boot_volumes = [resource] - identity_client.instances = [resource] - identity_client.file_systems = [resource] - identity_client.databases = [resource] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.rules = [] - identity_client.configuration = resource - identity_client.users = [] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_oraclecloud_provider(), - ), - mock.patch( - "prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse.identity_client", - new=identity_client, - ), - ): - from prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse import ( - identity_password_policy_prevents_reuse, - ) - - check = identity_password_policy_prevents_reuse() - result = check.execute() - - assert isinstance(result, list) - - # If results exist, verify PASS findings - if len(result) > 0: - # Find PASS results - pass_results = [r for r in result if r.status == "PASS"] - - if pass_results: - # Detailed assertions on first PASS result - assert pass_results[0].status == "PASS" - assert pass_results[0].status_extended is not None - assert len(pass_results[0].status_extended) > 0 - - # Verify resource identification - assert pass_results[0].resource_id is not None - assert pass_results[0].resource_name is not None - assert pass_results[0].region is not None - assert pass_results[0].compartment_id is not None - - # Verify metadata - assert pass_results[0].check_metadata.Provider == "oraclecloud" - assert ( - pass_results[0].check_metadata.CheckID - == "identity_password_policy_prevents_reuse" + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=90, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, ) - assert pass_results[0].check_metadata.ServiceName == "identity" - - def test_resource_non_compliant(self): - """identity_password_policy_prevents_reuse: Resource fails the check (FAIL)""" - identity_client = mock.MagicMock() - identity_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} - identity_client.audited_tenancy = OCI_TENANCY_ID - - # Mock a non-compliant resource - resource = mock.MagicMock() - resource.id = "ocid1.resource.oc1.iad.bbbbbbbexample" - resource.name = "non-compliant-resource" - resource.region = OCI_REGION - resource.compartment_id = OCI_COMPARTMENT_ID - resource.lifecycle_state = "ACTIVE" - resource.tags = {"Environment": "Development"} - - # Set attributes that make the resource non-compliant - resource.versioning = "Disabled" - resource.is_auto_rotation_enabled = False - resource.rotation_interval_in_days = None - resource.public_access_type = "ObjectRead" - resource.logging_enabled = False - resource.kms_key_id = None - resource.in_transit_encryption = "DISABLED" - resource.is_secure_boot_enabled = False - resource.legacy_endpoint_disabled = False - resource.is_legacy_imds_endpoint_disabled = False - - # Mock client with non-compliant resource - identity_client.buckets = [resource] - identity_client.keys = [resource] - identity_client.volumes = [resource] - identity_client.boot_volumes = [resource] - identity_client.instances = [resource] - identity_client.file_systems = [resource] - identity_client.databases = [resource] - identity_client.security_lists = [] - identity_client.security_groups = [] - identity_client.rules = [] - identity_client.configuration = resource - identity_client.users = [] + ] + ) + ] with ( mock.patch( @@ -201,29 +99,165 @@ class Test_identity_password_policy_prevents_reuse: check = identity_password_policy_prevents_reuse() result = check.execute() - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "24 passwords" in result[0].status_extended + assert result[0].resource_id == POLICY_ID - # Verify FAIL findings exist - if len(result) > 0: - # Find FAIL results - fail_results = [r for r in result if r.status == "FAIL"] - - if fail_results: - # Detailed assertions on first FAIL result - assert fail_results[0].status == "FAIL" - assert fail_results[0].status_extended is not None - assert len(fail_results[0].status_extended) > 0 - - # Verify resource identification - assert fail_results[0].resource_id is not None - assert fail_results[0].resource_name is not None - assert fail_results[0].region is not None - assert fail_results[0].compartment_id is not None - - # Verify metadata - assert fail_results[0].check_metadata.Provider == "oraclecloud" - assert ( - fail_results[0].check_metadata.CheckID - == "identity_password_policy_prevents_reuse" + def test_policy_insufficient_history(self): + """Password history < 24 → FAIL.""" + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=90, + num_passwords_in_history=5, + password_expire_warning=7, + min_password_age=1, ) - assert fail_results[0].check_metadata.ServiceName == "identity" + ] + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse import ( + identity_password_policy_prevents_reuse, + ) + + check = identity_password_policy_prevents_reuse() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "5 passwords" in result[0].status_extended + + def test_policy_no_history_configured(self): + """No password history configured → FAIL.""" + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=90, + num_passwords_in_history=None, + password_expire_warning=7, + min_password_age=1, + ) + ] + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse import ( + identity_password_policy_prevents_reuse, + ) + + check = identity_password_policy_prevents_reuse() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not have password history" in result[0].status_extended + + def test_domain_no_policies(self): + """Domain with no password policies → FAIL.""" + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [_make_domain([])] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse import ( + identity_password_policy_prevents_reuse, + ) + + check = identity_password_policy_prevents_reuse() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no password policy configured" in result[0].status_extended + + def test_system_managed_policies_excluded(self): + """System-managed policies should not appear in domain.password_policies. + + This is a regression test: SimplePasswordPolicy and StandardPasswordPolicy + are filtered at the service layer, so checks never see them. + """ + identity_client = mock.MagicMock() + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.domains = [ + _make_domain( + [ + DomainPasswordPolicy( + id=POLICY_ID, + name=POLICY_NAME, + description="Custom policy", + min_length=14, + password_expires_after=90, + num_passwords_in_history=24, + password_expire_warning=7, + min_password_age=1, + ) + ] + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse.identity_client", + new=identity_client, + ), + ): + from prowler.providers.oraclecloud.services.identity.identity_password_policy_prevents_reuse.identity_password_policy_prevents_reuse import ( + identity_password_policy_prevents_reuse, + ) + + check = identity_password_policy_prevents_reuse() + result = check.execute() + + # Only 1 finding for the custom policy, none for system-managed + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == POLICY_ID diff --git a/tests/providers/oraclecloud/services/kms/kms_key_rotation_enabled/oraclecloud_kms_key_rotation_enabled_test.py b/tests/providers/oraclecloud/services/kms/kms_key_rotation_enabled/oraclecloud_kms_key_rotation_enabled_test.py index bc9ec964a8..2b76d0c2b9 100644 --- a/tests/providers/oraclecloud/services/kms/kms_key_rotation_enabled/oraclecloud_kms_key_rotation_enabled_test.py +++ b/tests/providers/oraclecloud/services/kms/kms_key_rotation_enabled/oraclecloud_kms_key_rotation_enabled_test.py @@ -1,5 +1,7 @@ +from datetime import datetime, timedelta, timezone from unittest import mock +from prowler.providers.oraclecloud.services.kms.kms_service import Key from tests.providers.oraclecloud.oci_fixtures import ( OCI_COMPARTMENT_ID, OCI_REGION, @@ -7,36 +9,17 @@ from tests.providers.oraclecloud.oci_fixtures import ( set_mocked_oraclecloud_provider, ) +KEY_ID = "ocid1.key.oc1.iad.aaaaaaaexample" +KEY_NAME = "test-key" + class Test_kms_key_rotation_enabled: - def test_no_resources(self): - """kms_key_rotation_enabled: No resources to check""" + def test_no_keys(self): + """No keys → empty findings.""" kms_client = mock.MagicMock() kms_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} kms_client.audited_tenancy = OCI_TENANCY_ID - - # Mock empty collections - kms_client.rules = [] - kms_client.topics = [] - kms_client.subscriptions = [] - kms_client.users = [] - kms_client.groups = [] - kms_client.policies = [] - kms_client.compartments = [] - kms_client.instances = [] - kms_client.volumes = [] - kms_client.boot_volumes = [] - kms_client.buckets = [] kms_client.keys = [] - kms_client.file_systems = [] - kms_client.databases = [] - kms_client.security_lists = [] - kms_client.security_groups = [] - kms_client.subnets = [] - kms_client.vcns = [] - kms_client.configuration = None - kms_client.active_non_root_compartments = [] - kms_client.password_policy = None with ( mock.patch( @@ -55,49 +38,24 @@ class Test_kms_key_rotation_enabled: check = kms_key_rotation_enabled() result = check.execute() - # Verify result is a list (empty or with findings) - assert isinstance(result, list) + assert result == [] - def test_resource_compliant(self): - """kms_key_rotation_enabled: Resource passes the check (PASS)""" + def test_key_with_auto_rotation_enabled(self): + """Key with auto-rotation enabled → PASS.""" kms_client = mock.MagicMock() kms_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} kms_client.audited_tenancy = OCI_TENANCY_ID - - # Mock a compliant resource - resource = mock.MagicMock() - resource.id = "ocid1.resource.oc1.iad.aaaaaaaexample" - resource.name = "compliant-resource" - resource.region = OCI_REGION - resource.compartment_id = OCI_COMPARTMENT_ID - resource.lifecycle_state = "ACTIVE" - resource.tags = {"Environment": "Production"} - - # Set attributes that make the resource compliant - resource.versioning = "Enabled" - resource.is_auto_rotation_enabled = True - resource.rotation_interval_in_days = 90 - resource.public_access_type = "NoPublicAccess" - resource.logging_enabled = True - resource.kms_key_id = "ocid1.key.oc1.iad.aaaaaaaexample" - resource.in_transit_encryption = "ENABLED" - resource.is_secure_boot_enabled = True - resource.legacy_endpoint_disabled = True - resource.is_legacy_imds_endpoint_disabled = True - - # Mock client with compliant resource - kms_client.buckets = [resource] - kms_client.keys = [resource] - kms_client.volumes = [resource] - kms_client.boot_volumes = [resource] - kms_client.instances = [resource] - kms_client.file_systems = [resource] - kms_client.databases = [resource] - kms_client.security_lists = [] - kms_client.security_groups = [] - kms_client.rules = [] - kms_client.configuration = resource - kms_client.users = [] + kms_client.keys = [ + Key( + id=KEY_ID, + name=KEY_NAME, + compartment_id=OCI_COMPARTMENT_ID, + region=OCI_REGION, + lifecycle_state="ENABLED", + is_auto_rotation_enabled=True, + rotation_interval_in_days=90, + ) + ] with ( mock.patch( @@ -116,73 +74,32 @@ class Test_kms_key_rotation_enabled: check = kms_key_rotation_enabled() result = check.execute() - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "auto-rotation enabled" in result[0].status_extended + assert result[0].resource_id == KEY_ID + assert result[0].resource_name == KEY_NAME + assert result[0].region == OCI_REGION + assert result[0].compartment_id == OCI_COMPARTMENT_ID - # If results exist, verify PASS findings - if len(result) > 0: - # Find PASS results - pass_results = [r for r in result if r.status == "PASS"] - - if pass_results: - # Detailed assertions on first PASS result - assert pass_results[0].status == "PASS" - assert pass_results[0].status_extended is not None - assert len(pass_results[0].status_extended) > 0 - - # Verify resource identification - assert pass_results[0].resource_id is not None - assert pass_results[0].resource_name is not None - assert pass_results[0].region is not None - assert pass_results[0].compartment_id is not None - - # Verify metadata - assert pass_results[0].check_metadata.Provider == "oraclecloud" - assert ( - pass_results[0].check_metadata.CheckID - == "kms_key_rotation_enabled" - ) - assert pass_results[0].check_metadata.ServiceName == "kms" - - def test_resource_non_compliant(self): - """kms_key_rotation_enabled: Resource fails the check (FAIL)""" + def test_key_manually_rotated_within_365_days(self): + """Key manually rotated within last 365 days (no auto-rotation) → PASS.""" kms_client = mock.MagicMock() kms_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} kms_client.audited_tenancy = OCI_TENANCY_ID - - # Mock a non-compliant resource - resource = mock.MagicMock() - resource.id = "ocid1.resource.oc1.iad.bbbbbbbexample" - resource.name = "non-compliant-resource" - resource.region = OCI_REGION - resource.compartment_id = OCI_COMPARTMENT_ID - resource.lifecycle_state = "ACTIVE" - resource.tags = {"Environment": "Development"} - - # Set attributes that make the resource non-compliant - resource.versioning = "Disabled" - resource.is_auto_rotation_enabled = False - resource.rotation_interval_in_days = None - resource.public_access_type = "ObjectRead" - resource.logging_enabled = False - resource.kms_key_id = None - resource.in_transit_encryption = "DISABLED" - resource.is_secure_boot_enabled = False - resource.legacy_endpoint_disabled = False - resource.is_legacy_imds_endpoint_disabled = False - - # Mock client with non-compliant resource - kms_client.buckets = [resource] - kms_client.keys = [resource] - kms_client.volumes = [resource] - kms_client.boot_volumes = [resource] - kms_client.instances = [resource] - kms_client.file_systems = [resource] - kms_client.databases = [resource] - kms_client.security_lists = [] - kms_client.security_groups = [] - kms_client.rules = [] - kms_client.configuration = resource - kms_client.users = [] + kms_client.keys = [ + Key( + id=KEY_ID, + name=KEY_NAME, + compartment_id=OCI_COMPARTMENT_ID, + region=OCI_REGION, + lifecycle_state="ENABLED", + is_auto_rotation_enabled=False, + rotation_interval_in_days=None, + current_key_version_time_created=datetime.now(timezone.utc) + - timedelta(days=100), + ) + ] with ( mock.patch( @@ -201,29 +118,88 @@ class Test_kms_key_rotation_enabled: check = kms_key_rotation_enabled() result = check.execute() - assert isinstance(result, list) + assert len(result) == 1 + assert result[0].status == "PASS" + assert "manually rotated" in result[0].status_extended + assert result[0].resource_id == KEY_ID - # Verify FAIL findings exist - if len(result) > 0: - # Find FAIL results - fail_results = [r for r in result if r.status == "FAIL"] + def test_key_manually_rotated_over_365_days_ago(self): + """Key manually rotated more than 365 days ago (no auto-rotation) → FAIL.""" + kms_client = mock.MagicMock() + kms_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} + kms_client.audited_tenancy = OCI_TENANCY_ID + kms_client.keys = [ + Key( + id=KEY_ID, + name=KEY_NAME, + compartment_id=OCI_COMPARTMENT_ID, + region=OCI_REGION, + lifecycle_state="ENABLED", + is_auto_rotation_enabled=False, + rotation_interval_in_days=None, + current_key_version_time_created=datetime.now(timezone.utc) + - timedelta(days=400), + ) + ] - if fail_results: - # Detailed assertions on first FAIL result - assert fail_results[0].status == "FAIL" - assert fail_results[0].status_extended is not None - assert len(fail_results[0].status_extended) > 0 + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", + new=kms_client, + ), + ): + from prowler.providers.oraclecloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( + kms_key_rotation_enabled, + ) - # Verify resource identification - assert fail_results[0].resource_id is not None - assert fail_results[0].resource_name is not None - assert fail_results[0].region is not None - assert fail_results[0].compartment_id is not None + check = kms_key_rotation_enabled() + result = check.execute() - # Verify metadata - assert fail_results[0].check_metadata.Provider == "oraclecloud" - assert ( - fail_results[0].check_metadata.CheckID - == "kms_key_rotation_enabled" - ) - assert fail_results[0].check_metadata.ServiceName == "kms" + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "not been rotated" in result[0].status_extended + assert result[0].resource_id == KEY_ID + + def test_key_no_rotation_at_all(self): + """Key with no auto-rotation and no version info → FAIL.""" + kms_client = mock.MagicMock() + kms_client.audited_compartments = {OCI_COMPARTMENT_ID: mock.MagicMock()} + kms_client.audited_tenancy = OCI_TENANCY_ID + kms_client.keys = [ + Key( + id=KEY_ID, + name=KEY_NAME, + compartment_id=OCI_COMPARTMENT_ID, + region=OCI_REGION, + lifecycle_state="ENABLED", + is_auto_rotation_enabled=False, + rotation_interval_in_days=None, + current_key_version_time_created=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch( + "prowler.providers.oraclecloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled.kms_client", + new=kms_client, + ), + ): + from prowler.providers.oraclecloud.services.kms.kms_key_rotation_enabled.kms_key_rotation_enabled import ( + kms_key_rotation_enabled, + ) + + check = kms_key_rotation_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "not been rotated" in result[0].status_extended + assert result[0].resource_id == KEY_ID diff --git a/tests/providers/vercel/lib/mutelist/fixtures/vercel_mutelist.yaml b/tests/providers/vercel/lib/mutelist/fixtures/vercel_mutelist.yaml new file mode 100644 index 0000000000..4cacaca5c4 --- /dev/null +++ b/tests/providers/vercel/lib/mutelist/fixtures/vercel_mutelist.yaml @@ -0,0 +1,9 @@ +Mutelist: + Accounts: + "team_test123": + Checks: + "project_deployment_protection_enabled": + Regions: + - "*" + Resources: + - "prj_test789" diff --git a/tests/providers/vercel/lib/mutelist/vercel_mutelist_test.py b/tests/providers/vercel/lib/mutelist/vercel_mutelist_test.py new file mode 100644 index 0000000000..c5cae1ba47 --- /dev/null +++ b/tests/providers/vercel/lib/mutelist/vercel_mutelist_test.py @@ -0,0 +1,93 @@ +from unittest.mock import MagicMock + +import yaml + +from prowler.providers.vercel.lib.mutelist.mutelist import VercelMutelist + +MUTELIST_FIXTURE_PATH = ( + "tests/providers/vercel/lib/mutelist/fixtures/vercel_mutelist.yaml" +) + + +class TestVercelMutelist: + def test_get_mutelist_file_from_local_file(self): + mutelist = VercelMutelist(mutelist_path=MUTELIST_FIXTURE_PATH) + + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + assert mutelist.mutelist == mutelist_fixture + assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH + + def test_get_mutelist_file_from_local_file_non_existent(self): + mutelist_path = "tests/providers/vercel/lib/mutelist/fixtures/not_present" + mutelist = VercelMutelist(mutelist_path=mutelist_path) + + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path == mutelist_path + + def test_validate_mutelist_not_valid_key(self): + mutelist_path = MUTELIST_FIXTURE_PATH + with open(mutelist_path) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"] + del mutelist_fixture["Accounts"] + + mutelist = VercelMutelist(mutelist_content=mutelist_fixture) + + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path is None + + def test_is_finding_muted(self): + mutelist_content = { + "Accounts": { + "team_test123": { + "Checks": { + "project_deployment_protection_enabled": { + "Regions": ["*"], + "Resources": ["prj_test789"], + } + } + } + } + } + + mutelist = VercelMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "project_deployment_protection_enabled" + finding.status = "FAIL" + finding.resource_id = "prj_test789" + finding.resource_name = "my-test-project" + finding.resource_tags = [] + + assert mutelist.is_finding_muted(finding, "team_test123") + + def test_is_finding_not_muted(self): + mutelist_content = { + "Accounts": { + "team_test123": { + "Checks": { + "project_deployment_protection_enabled": { + "Regions": ["*"], + "Resources": ["other-project-id"], + } + } + } + } + } + + mutelist = VercelMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "project_deployment_protection_enabled" + finding.status = "FAIL" + finding.resource_id = "prj_test789" + finding.resource_name = "my-test-project" + finding.resource_tags = [] + + assert not mutelist.is_finding_muted(finding, "team_test123") diff --git a/tests/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens_test.py b/tests/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens_test.py new file mode 100644 index 0000000000..873cf99a41 --- /dev/null +++ b/tests/providers/vercel/services/authentication/authentication_no_stale_tokens/authentication_no_stale_tokens_test.py @@ -0,0 +1,152 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.vercel.services.authentication.authentication_service import ( + VercelAuthToken, +) +from tests.providers.vercel.vercel_fixtures import set_mocked_vercel_provider + + +class Test_authentication_no_stale_tokens: + def test_no_tokens(self): + authentication_client = mock.MagicMock + authentication_client.audit_config = {"stale_token_threshold_days": 90} + authentication_client.tokens = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens import ( + authentication_no_stale_tokens, + ) + + check = authentication_no_stale_tokens() + result = check.execute() + assert len(result) == 0 + + def test_token_active_recently(self): + token_id = "tok_1" + token_name = "Recent Token" + active_at = datetime.now(timezone.utc) - timedelta(days=10) + authentication_client = mock.MagicMock + authentication_client.audit_config = {"stale_token_threshold_days": 90} + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + active_at=active_at, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens import ( + authentication_no_stale_tokens, + ) + + check = authentication_no_stale_tokens() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) was last active on {active_at.strftime('%Y-%m-%d %H:%M UTC')} (within the last 90 days)." + ) + assert result[0].team_id is None + + def test_token_stale_90_days(self): + token_id = "tok_2" + token_name = "Stale Token" + active_at = datetime.now(timezone.utc) - timedelta(days=120) + days_inactive = (datetime.now(timezone.utc) - active_at).days + authentication_client = mock.MagicMock + authentication_client.audit_config = {"stale_token_threshold_days": 90} + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + active_at=active_at, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens import ( + authentication_no_stale_tokens, + ) + + check = authentication_no_stale_tokens() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) has not been used for {days_inactive} days (last active: {active_at.strftime('%Y-%m-%d %H:%M UTC')}). Threshold is 90 days." + ) + assert result[0].team_id is None + + def test_token_no_activity(self): + token_id = "tok_3" + token_name = "Never Used Token" + authentication_client = mock.MagicMock + authentication_client.audit_config = {"stale_token_threshold_days": 90} + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + active_at=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_no_stale_tokens.authentication_no_stale_tokens import ( + authentication_no_stale_tokens, + ) + + check = authentication_no_stale_tokens() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) has no recorded activity and is considered stale." + ) + assert result[0].team_id is None diff --git a/tests/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired_test.py b/tests/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired_test.py new file mode 100644 index 0000000000..47f19ad79e --- /dev/null +++ b/tests/providers/vercel/services/authentication/authentication_token_not_expired/authentication_token_not_expired_test.py @@ -0,0 +1,228 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.lib.check.models import Severity +from prowler.providers.vercel.services.authentication.authentication_service import ( + VercelAuthToken, +) +from tests.providers.vercel.vercel_fixtures import set_mocked_vercel_provider + + +class Test_authentication_token_not_expired: + def test_no_tokens(self): + authentication_client = mock.MagicMock + authentication_client.tokens = {} + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 0 + + def test_token_not_expired(self): + token_id = "tok_1" + token_name = "My Token" + expires_at = datetime.now(timezone.utc) + timedelta(days=30) + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=expires_at, + ) + } + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) is valid and expires on {expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + assert result[0].team_id is None + + def test_token_expired(self): + token_id = "tok_2" + token_name = "Old Token" + expires_at = datetime.now(timezone.utc) - timedelta(days=1) + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=expires_at, + ) + } + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) has expired on {expires_at.strftime('%Y-%m-%d %H:%M UTC')}." + ) + assert result[0].team_id is None + + def test_token_no_expiration(self): + token_id = "tok_3" + token_name = "Permanent Token" + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=None, + ) + } + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Token '{token_name}' ({token_id}) does not have an expiration date set and is currently valid." + ) + assert result[0].team_id is None + + def test_token_about_to_expire(self): + """Token expiring within the default 7-day threshold gets FAIL with medium severity.""" + token_id = "tok_4" + token_name = "Expiring Soon Token" + expires_at = datetime.now(timezone.utc) + timedelta(days=3, hours=12) + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=expires_at, + ) + } + authentication_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == token_id + assert result[0].resource_name == token_name + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.medium + assert "is about to expire in 3 days" in result[0].status_extended + assert result[0].team_id is None + + def test_token_about_to_expire_custom_threshold(self): + """Token expiring within a custom threshold (14 days) gets FAIL with medium severity.""" + token_id = "tok_5" + token_name = "Custom Threshold Token" + expires_at = datetime.now(timezone.utc) + timedelta(days=10, hours=12) + authentication_client = mock.MagicMock + authentication_client.tokens = { + token_id: VercelAuthToken( + id=token_id, + name=token_name, + expires_at=expires_at, + ) + } + authentication_client.audit_config = {"days_to_expire_threshold": 14} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired.authentication_client", + new=authentication_client, + ), + ): + from prowler.providers.vercel.services.authentication.authentication_token_not_expired.authentication_token_not_expired import ( + authentication_token_not_expired, + ) + + check = authentication_token_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.medium + assert "is about to expire in 10 days" in result[0].status_extended diff --git a/tests/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target_test.py b/tests/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target_test.py new file mode 100644 index 0000000000..3a92202ddd --- /dev/null +++ b/tests/providers/vercel/services/deployment/deployment_production_uses_stable_target/deployment_production_uses_stable_target_test.py @@ -0,0 +1,151 @@ +from unittest import mock + +from prowler.providers.vercel.services.deployment.deployment_service import ( + VercelDeployment, +) +from tests.providers.vercel.vercel_fixtures import ( + DEPLOYMENT_ID, + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_deployment_production_uses_stable_target: + def test_no_deployments(self): + deployment_client = mock.MagicMock + deployment_client.deployments = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target.deployment_client", + new=deployment_client, + ), + ): + from prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target import ( + deployment_production_uses_stable_target, + ) + + check = deployment_production_uses_stable_target() + result = check.execute() + assert len(result) == 0 + + def test_stable_branch(self): + deployment_client = mock.MagicMock + deployment_client.deployments = { + DEPLOYMENT_ID: VercelDeployment( + id=DEPLOYMENT_ID, + name="my-app-abc123", + target="production", + git_source={"branch": "main"}, + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ) + } + deployment_client.audit_config = {"stable_branches": ["main", "master"]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target.deployment_client", + new=deployment_client, + ), + ): + from prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target import ( + deployment_production_uses_stable_target, + ) + + check = deployment_production_uses_stable_target() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DEPLOYMENT_ID + assert result[0].resource_name == "my-app-abc123" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Production deployment my-app-abc123 ({DEPLOYMENT_ID}) is sourced from stable branch 'main'." + ) + assert result[0].team_id == TEAM_ID + + def test_non_stable_branch(self): + deployment_client = mock.MagicMock + deployment_client.deployments = { + DEPLOYMENT_ID: VercelDeployment( + id=DEPLOYMENT_ID, + name="my-app-abc123", + target="production", + git_source={"branch": "feature-xyz"}, + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ) + } + deployment_client.audit_config = {"stable_branches": ["main", "master"]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target.deployment_client", + new=deployment_client, + ), + ): + from prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target import ( + deployment_production_uses_stable_target, + ) + + check = deployment_production_uses_stable_target() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DEPLOYMENT_ID + assert result[0].resource_name == "my-app-abc123" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Production deployment my-app-abc123 ({DEPLOYMENT_ID}) is sourced from branch 'feature-xyz' instead of a configured stable branch (main, master)." + ) + assert result[0].team_id == TEAM_ID + + def test_non_production_skipped(self): + deployment_client = mock.MagicMock + deployment_client.deployments = { + DEPLOYMENT_ID: VercelDeployment( + id=DEPLOYMENT_ID, + name="my-app-abc123", + target="preview", + git_source={"branch": "feature-xyz"}, + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ) + } + deployment_client.audit_config = {"stable_branches": ["main", "master"]} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target.deployment_client", + new=deployment_client, + ), + ): + from prowler.providers.vercel.services.deployment.deployment_production_uses_stable_target.deployment_production_uses_stable_target import ( + deployment_production_uses_stable_target, + ) + + check = deployment_production_uses_stable_target() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured_test.py b/tests/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured_test.py new file mode 100644 index 0000000000..2562a27a14 --- /dev/null +++ b/tests/providers/vercel/services/domain/domain_dns_properly_configured/domain_dns_properly_configured_test.py @@ -0,0 +1,108 @@ +from unittest import mock + +from prowler.providers.vercel.services.domain.domain_service import VercelDomain +from tests.providers.vercel.vercel_fixtures import ( + DOMAIN_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + +DOMAIN_ID = "dom_test001" + + +class Test_domain_dns_properly_configured: + def test_no_domains(self): + domain_client = mock.MagicMock + domain_client.domains = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured import ( + domain_dns_properly_configured, + ) + + check = domain_dns_properly_configured() + result = check.execute() + assert len(result) == 0 + + def test_configured(self): + domain_client = mock.MagicMock + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id=DOMAIN_ID, + configured=True, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured import ( + domain_dns_properly_configured, + ) + + check = domain_dns_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DOMAIN_ID + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Domain {DOMAIN_NAME} has DNS properly configured." + ) + assert result[0].team_id == TEAM_ID + + def test_not_configured(self): + domain_client = mock.MagicMock + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id=DOMAIN_ID, + configured=False, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_dns_properly_configured.domain_dns_properly_configured import ( + domain_dns_properly_configured, + ) + + check = domain_dns_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DOMAIN_ID + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Domain {DOMAIN_NAME} does not have DNS properly configured. The domain may not be resolving to Vercel's infrastructure." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid_test.py b/tests/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid_test.py new file mode 100644 index 0000000000..4b906731ab --- /dev/null +++ b/tests/providers/vercel/services/domain/domain_ssl_certificate_valid/domain_ssl_certificate_valid_test.py @@ -0,0 +1,236 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.vercel.services.domain.domain_service import ( + VercelDomain, + VercelSSLCertificate, +) +from tests.providers.vercel.vercel_fixtures import ( + DOMAIN_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_domain_ssl_certificate_valid: + def test_no_domains(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 0 + + def test_ssl_valid_not_expiring_soon(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=VercelSSLCertificate( + id="cert_1", + expires_at=datetime.now(timezone.utc) + timedelta(days=90), + auto_renew=True, + ), + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "PASS" + assert "valid SSL certificate" in result[0].status_extended + assert result[0].team_id == TEAM_ID + + def test_ssl_missing(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=None, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Domain {DOMAIN_NAME} does not have an SSL certificate provisioned." + ) + assert result[0].team_id == TEAM_ID + + def test_ssl_expired(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=VercelSSLCertificate( + id="cert_1", + expires_at=datetime.now(timezone.utc) - timedelta(days=10), + auto_renew=False, + ), + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert "expired" in result[0].status_extended + assert result[0].team_id == TEAM_ID + + def test_ssl_expiring_soon(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=VercelSSLCertificate( + id="cert_1", + expires_at=datetime.now(timezone.utc) + timedelta(days=3), + auto_renew=False, + ), + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert "expiring" in result[0].status_extended + assert result[0].team_id == TEAM_ID + + def test_ssl_no_expiry_date(self): + domain_client = mock.MagicMock + domain_client.audit_config = {"days_to_expire_threshold": 7} + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id="dom_test", + verified=True, + ssl_certificate=VercelSSLCertificate( + id="cert_1", + expires_at=None, + auto_renew=True, + ), + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_ssl_certificate_valid.domain_ssl_certificate_valid import ( + domain_ssl_certificate_valid, + ) + + check = domain_ssl_certificate_valid() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == "dom_test" + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "PASS" + assert "provisioned" in result[0].status_extended + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/domain/domain_verified/domain_verified_test.py b/tests/providers/vercel/services/domain/domain_verified/domain_verified_test.py new file mode 100644 index 0000000000..3634b69a07 --- /dev/null +++ b/tests/providers/vercel/services/domain/domain_verified/domain_verified_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.vercel.services.domain.domain_service import VercelDomain +from tests.providers.vercel.vercel_fixtures import ( + DOMAIN_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + +DOMAIN_ID = "dom_test001" + + +class Test_domain_verified: + def test_no_domains(self): + domain_client = mock.MagicMock + domain_client.domains = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_verified.domain_verified.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_verified.domain_verified import ( + domain_verified, + ) + + check = domain_verified() + result = check.execute() + assert len(result) == 0 + + def test_verified(self): + domain_client = mock.MagicMock + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id=DOMAIN_ID, + verified=True, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_verified.domain_verified.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_verified.domain_verified import ( + domain_verified, + ) + + check = domain_verified() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DOMAIN_ID + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "PASS" + assert result[0].status_extended == f"Domain {DOMAIN_NAME} is verified." + assert result[0].team_id == TEAM_ID + + def test_not_verified(self): + domain_client = mock.MagicMock + domain_client.domains = { + DOMAIN_NAME: VercelDomain( + name=DOMAIN_NAME, + id=DOMAIN_ID, + verified=False, + team_id=TEAM_ID, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.domain.domain_verified.domain_verified.domain_client", + new=domain_client, + ), + ): + from prowler.providers.vercel.services.domain.domain_verified.domain_verified import ( + domain_verified, + ) + + check = domain_verified() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == DOMAIN_ID + assert result[0].resource_name == DOMAIN_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Domain {DOMAIN_NAME} is not verified. The domain may not be serving traffic correctly." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled_test.py b/tests/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled_test.py new file mode 100644 index 0000000000..8738107467 --- /dev/null +++ b/tests/providers/vercel/services/project/project_auto_expose_system_env_disabled/project_auto_expose_system_env_disabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_auto_expose_system_env_disabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled import ( + project_auto_expose_system_env_disabled, + ) + + check = project_auto_expose_system_env_disabled() + result = check.execute() + assert len(result) == 0 + + def test_auto_expose_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + auto_expose_system_envs=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled import ( + project_auto_expose_system_env_disabled, + ) + + check = project_auto_expose_system_env_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not automatically expose system environment variables to the build process." + ) + assert result[0].team_id == TEAM_ID + + def test_auto_expose_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + auto_expose_system_envs=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_auto_expose_system_env_disabled.project_auto_expose_system_env_disabled import ( + project_auto_expose_system_env_disabled, + ) + + check = project_auto_expose_system_env_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} automatically exposes system environment variables to the build process." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled_test.py b/tests/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled_test.py new file mode 100644 index 0000000000..a37ce91048 --- /dev/null +++ b/tests/providers/vercel/services/project/project_deployment_protection_enabled/project_deployment_protection_enabled_test.py @@ -0,0 +1,151 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + DeploymentProtectionConfig, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_deployment_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled import ( + project_deployment_protection_enabled, + ) + + check = project_deployment_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_deployment_protection_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + deployment_protection=DeploymentProtectionConfig( + level="standard", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled import ( + project_deployment_protection_enabled, + ) + + check = project_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has deployment protection enabled with level 'standard' on preview deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_deployment_protection_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + deployment_protection=DeploymentProtectionConfig( + level="none", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled import ( + project_deployment_protection_enabled, + ) + + check = project_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on preview deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_deployment_protection_none(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + deployment_protection=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_deployment_protection_enabled.project_deployment_protection_enabled import ( + project_deployment_protection_enabled, + ) + + check = project_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on preview deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled_test.py b/tests/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled_test.py new file mode 100644 index 0000000000..63bf279e06 --- /dev/null +++ b/tests/providers/vercel/services/project/project_directory_listing_disabled/project_directory_listing_disabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_directory_listing_disabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled import ( + project_directory_listing_disabled, + ) + + check = project_directory_listing_disabled() + result = check.execute() + assert len(result) == 0 + + def test_listing_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + directory_listing=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled import ( + project_directory_listing_disabled, + ) + + check = project_directory_listing_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has directory listing disabled." + ) + assert result[0].team_id == TEAM_ID + + def test_listing_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + directory_listing=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_directory_listing_disabled.project_directory_listing_disabled import ( + project_directory_listing_disabled, + ) + + check = project_directory_listing_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has directory listing enabled, which may expose the project's file structure to visitors." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target_test.py b/tests/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target_test.py new file mode 100644 index 0000000000..633d28a8e3 --- /dev/null +++ b/tests/providers/vercel/services/project/project_environment_no_overly_broad_target/project_environment_no_overly_broad_target_test.py @@ -0,0 +1,126 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + VercelEnvironmentVariable, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_environment_no_overly_broad_target: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target import ( + project_environment_no_overly_broad_target, + ) + + check = project_environment_no_overly_broad_target() + result = check.execute() + assert len(result) == 0 + + def test_no_broad_vars(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_001", + key="DATABASE_URL", + type="encrypted", + target=["production"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target import ( + project_environment_no_overly_broad_target, + ) + + check = project_environment_no_overly_broad_target() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no environment variables targeting all three environments simultaneously." + ) + assert result[0].team_id == TEAM_ID + + def test_var_targets_all_envs(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_002", + key="SHARED_VAR", + type="plain", + target=["production", "preview", "development"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_overly_broad_target.project_environment_no_overly_broad_target import ( + project_environment_no_overly_broad_target, + ) + + check = project_environment_no_overly_broad_target() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has 1 environment variable(s) targeting all three environments: SHARED_VAR." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type_test.py b/tests/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type_test.py new file mode 100644 index 0000000000..62de735634 --- /dev/null +++ b/tests/providers/vercel/services/project/project_environment_no_secrets_in_plain_type/project_environment_no_secrets_in_plain_type_test.py @@ -0,0 +1,172 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + VercelEnvironmentVariable, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_environment_no_secrets_in_plain_type: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.audit_config = {} + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type import ( + project_environment_no_secrets_in_plain_type, + ) + + check = project_environment_no_secrets_in_plain_type() + result = check.execute() + assert len(result) == 0 + + def test_no_secret_keys_plain(self): + project_client = mock.MagicMock + project_client.audit_config = {} + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_001", + key="DATABASE_PASSWORD", + type="encrypted", + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type import ( + project_environment_no_secrets_in_plain_type, + ) + + check = project_environment_no_secrets_in_plain_type() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no secret-like environment variables stored as plain text." + ) + assert result[0].team_id == TEAM_ID + + def test_secret_key_plain(self): + project_client = mock.MagicMock + project_client.audit_config = {} + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_002", + key="MY_API_KEY", + type="plain", + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type import ( + project_environment_no_secrets_in_plain_type, + ) + + check = project_environment_no_secrets_in_plain_type() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has 1 secret-like environment variable(s) stored as plain text: MY_API_KEY." + ) + assert result[0].team_id == TEAM_ID + + def test_non_secret_key_plain(self): + project_client = mock.MagicMock + project_client.audit_config = {} + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_003", + key="APP_NAME", + type="plain", + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_no_secrets_in_plain_type.project_environment_no_secrets_in_plain_type import ( + project_environment_no_secrets_in_plain_type, + ) + + check = project_environment_no_secrets_in_plain_type() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no secret-like environment variables stored as plain text." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview_test.py b/tests/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview_test.py new file mode 100644 index 0000000000..b8796ef19e --- /dev/null +++ b/tests/providers/vercel/services/project/project_environment_production_vars_not_in_preview/project_environment_production_vars_not_in_preview_test.py @@ -0,0 +1,171 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + VercelEnvironmentVariable, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_environment_production_vars_not_in_preview: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview import ( + project_environment_production_vars_not_in_preview, + ) + + check = project_environment_production_vars_not_in_preview() + result = check.execute() + assert len(result) == 0 + + def test_prod_only_secret(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_001", + key="DB_PASSWORD", + type="secret", + target=["production"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview import ( + project_environment_production_vars_not_in_preview, + ) + + check = project_environment_production_vars_not_in_preview() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no sensitive production environment variables leaking to preview deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_prod_and_preview_secret(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_002", + key="DB_PASSWORD", + type="secret", + target=["production", "preview"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview import ( + project_environment_production_vars_not_in_preview, + ) + + check = project_environment_production_vars_not_in_preview() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has 1 sensitive production environment variable(s) also targeting preview: DB_PASSWORD." + ) + assert result[0].team_id == TEAM_ID + + def test_prod_and_preview_plain(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + environment_variables=[ + VercelEnvironmentVariable( + id="env_003", + key="APP_URL", + type="plain", + target=["production", "preview"], + project_id=PROJECT_ID, + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_environment_production_vars_not_in_preview.project_environment_production_vars_not_in_preview import ( + project_environment_production_vars_not_in_preview, + ) + + check = project_environment_production_vars_not_in_preview() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has no sensitive production environment variables leaking to preview deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled_test.py b/tests/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled_test.py new file mode 100644 index 0000000000..bd3dad4138 --- /dev/null +++ b/tests/providers/vercel/services/project/project_git_fork_protection_enabled/project_git_fork_protection_enabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_git_fork_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled import ( + project_git_fork_protection_enabled, + ) + + check = project_git_fork_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_fork_protection_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + git_fork_protection=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled import ( + project_git_fork_protection_enabled, + ) + + check = project_git_fork_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has Git fork protection enabled, preventing untrusted forks from accessing secrets." + ) + assert result[0].team_id == TEAM_ID + + def test_fork_protection_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + git_fork_protection=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_git_fork_protection_enabled.project_git_fork_protection_enabled import ( + project_git_fork_protection_enabled, + ) + + check = project_git_fork_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have Git fork protection enabled, allowing forks to access environment variables and trigger deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py b/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py new file mode 100644 index 0000000000..9de3eb874a --- /dev/null +++ b/tests/providers/vercel/services/project/project_password_protection_enabled/project_password_protection_enabled_test.py @@ -0,0 +1,144 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_password_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_password_protection_configured(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + password_protection={"deploymentType": "all"}, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has password protection configured to restrict access to deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_empty_dict_password_protection(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + password_protection={}, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have password protection configured for deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_no_password_protection(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + password_protection=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_password_protection_enabled.project_password_protection_enabled import ( + project_password_protection_enabled, + ) + + check = project_password_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have password protection configured for deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py b/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py new file mode 100644 index 0000000000..ff2517fbd3 --- /dev/null +++ b/tests/providers/vercel/services/project/project_production_deployment_protection_enabled/project_production_deployment_protection_enabled_test.py @@ -0,0 +1,151 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import ( + DeploymentProtectionConfig, + VercelProject, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_production_deployment_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_protection_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + production_deployment_protection=DeploymentProtectionConfig( + level="standard", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has production deployment protection enabled with level 'standard'." + ) + assert result[0].team_id == TEAM_ID + + def test_protection_none_level(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + production_deployment_protection=DeploymentProtectionConfig( + level="none", + ), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on production deployments." + ) + assert result[0].team_id == TEAM_ID + + def test_protection_null(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + production_deployment_protection=None, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_production_deployment_protection_enabled.project_production_deployment_protection_enabled import ( + project_production_deployment_protection_enabled, + ) + + check = project_production_deployment_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have deployment protection enabled on production deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py b/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py new file mode 100644 index 0000000000..19d1ce8885 --- /dev/null +++ b/tests/providers/vercel/services/project/project_skew_protection_enabled/project_skew_protection_enabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.project.project_service import VercelProject +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_project_skew_protection_enabled: + def test_no_projects(self): + project_client = mock.MagicMock + project_client.projects = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled import ( + project_skew_protection_enabled, + ) + + check = project_skew_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_skew_protection_enabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + skew_protection=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled import ( + project_skew_protection_enabled, + ) + + check = project_skew_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} has skew protection enabled, ensuring consistent deployment versions during rollouts." + ) + assert result[0].team_id == TEAM_ID + + def test_skew_protection_disabled(self): + project_client = mock.MagicMock + project_client.projects = { + PROJECT_ID: VercelProject( + id=PROJECT_ID, + name=PROJECT_NAME, + team_id=TEAM_ID, + skew_protection=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled.project_client", + new=project_client, + ), + ): + from prowler.providers.vercel.services.project.project_skew_protection_enabled.project_skew_protection_enabled import ( + project_skew_protection_enabled, + ) + + check = project_skew_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} does not have skew protection enabled, which may cause version mismatches during deployments." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py b/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py new file mode 100644 index 0000000000..72fc44f61e --- /dev/null +++ b/tests/providers/vercel/services/security/security_custom_rules_configured/security_custom_rules_configured_test.py @@ -0,0 +1,113 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_custom_rules_configured: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured import ( + security_custom_rules_configured, + ) + + check = security_custom_rules_configured() + result = check.execute() + assert len(result) == 0 + + def test_custom_rules_configured(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + custom_rules=[{"id": "rule1", "name": "Block bots"}], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured import ( + security_custom_rules_configured, + ) + + check = security_custom_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has 1 custom firewall rule(s) configured." + ) + assert result[0].team_id == TEAM_ID + + def test_no_custom_rules(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + custom_rules=[], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_custom_rules_configured.security_custom_rules_configured import ( + security_custom_rules_configured, + ) + + check = security_custom_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any custom firewall rules configured." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py b/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py new file mode 100644 index 0000000000..20e6224a24 --- /dev/null +++ b/tests/providers/vercel/services/security/security_ip_blocking_rules_configured/security_ip_blocking_rules_configured_test.py @@ -0,0 +1,113 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_ip_blocking_rules_configured: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured import ( + security_ip_blocking_rules_configured, + ) + + check = security_ip_blocking_rules_configured() + result = check.execute() + assert len(result) == 0 + + def test_ip_rules_configured(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ip_blocking_rules=[{"id": "rule1", "ip": "192.168.1.0/24"}], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured import ( + security_ip_blocking_rules_configured, + ) + + check = security_ip_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has 1 IP blocking rule(s) configured." + ) + assert result[0].team_id == TEAM_ID + + def test_no_ip_rules(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + ip_blocking_rules=[], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_ip_blocking_rules_configured.security_ip_blocking_rules_configured import ( + security_ip_blocking_rules_configured, + ) + + check = security_ip_blocking_rules_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any IP blocking rules configured." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py b/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py new file mode 100644 index 0000000000..2cc32c4e91 --- /dev/null +++ b/tests/providers/vercel/services/security/security_managed_rulesets_enabled/security_managed_rulesets_enabled_test.py @@ -0,0 +1,155 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_managed_rulesets_enabled: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 0 + + def test_managed_rulesets_enabled(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=True, + managed_rulesets={"owasp": True}, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has managed WAF rulesets enabled." + ) + assert result[0].team_id == TEAM_ID + + def test_managed_rulesets_disabled(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=False, + managed_rulesets={}, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have managed WAF rulesets enabled." + ) + assert result[0].team_id == TEAM_ID + + def test_managed_rulesets_plan_gated(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=False, + managed_rulesets=None, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_managed_rulesets_enabled.security_managed_rulesets_enabled import ( + security_managed_rulesets_enabled, + ) + + check = security_managed_rulesets_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) could not be assessed for managed rulesets. Enterprise plan required to access this feature." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py b/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py new file mode 100644 index 0000000000..1c00a3c358 --- /dev/null +++ b/tests/providers/vercel/services/security/security_rate_limiting_configured/security_rate_limiting_configured_test.py @@ -0,0 +1,113 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_rate_limiting_configured: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured import ( + security_rate_limiting_configured, + ) + + check = security_rate_limiting_configured() + result = check.execute() + assert len(result) == 0 + + def test_rate_limiting_configured(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + rate_limiting_rules=[{"id": "rule1", "max_requests": 100}], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured import ( + security_rate_limiting_configured, + ) + + check = security_rate_limiting_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has 1 rate limiting rule(s) configured." + ) + assert result[0].team_id == TEAM_ID + + def test_no_rate_limiting(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + rate_limiting_rules=[], + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_rate_limiting_configured.security_rate_limiting_configured import ( + security_rate_limiting_configured, + ) + + check = security_rate_limiting_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have any rate limiting rules configured." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py b/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py new file mode 100644 index 0000000000..1641868175 --- /dev/null +++ b/tests/providers/vercel/services/security/security_waf_enabled/security_waf_enabled_test.py @@ -0,0 +1,115 @@ +from unittest import mock + +from prowler.providers.vercel.services.security.security_service import ( + VercelFirewallConfig, +) +from tests.providers.vercel.vercel_fixtures import ( + PROJECT_ID, + PROJECT_NAME, + TEAM_ID, + set_mocked_vercel_provider, +) + + +class Test_security_waf_enabled: + def test_no_configs(self): + security_client = mock.MagicMock + security_client.firewall_configs = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import ( + security_waf_enabled, + ) + + check = security_waf_enabled() + result = check.execute() + assert len(result) == 0 + + def test_waf_enabled(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=True, + managed_rulesets={}, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import ( + security_waf_enabled, + ) + + check = security_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) has the Web Application Firewall enabled." + ) + assert result[0].team_id == TEAM_ID + + def test_waf_disabled(self): + security_client = mock.MagicMock + security_client.firewall_configs = { + PROJECT_ID: VercelFirewallConfig( + project_id=PROJECT_ID, + project_name=PROJECT_NAME, + team_id=TEAM_ID, + firewall_enabled=False, + managed_rulesets={}, + id=PROJECT_ID, + name=PROJECT_NAME, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled.security_client", + new=security_client, + ), + ): + from prowler.providers.vercel.services.security.security_waf_enabled.security_waf_enabled import ( + security_waf_enabled, + ) + + check = security_waf_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == PROJECT_ID + assert result[0].resource_name == PROJECT_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Project {PROJECT_NAME} ({PROJECT_ID}) does not have the Web Application Firewall enabled." + ) + assert result[0].team_id == TEAM_ID diff --git a/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py b/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py new file mode 100644 index 0000000000..7f08db7fa1 --- /dev/null +++ b/tests/providers/vercel/services/team/team_directory_sync_enabled/team_directory_sync_enabled_test.py @@ -0,0 +1,107 @@ +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import VercelTeam +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_directory_sync_enabled: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.teams = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled import ( + team_directory_sync_enabled, + ) + + check = team_directory_sync_enabled() + result = check.execute() + assert len(result) == 0 + + def test_directory_sync_enabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + directory_sync_enabled=True, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled import ( + team_directory_sync_enabled, + ) + + check = team_directory_sync_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has directory sync (SCIM) enabled for automated user provisioning." + ) + assert result[0].team_id == "" + + def test_directory_sync_disabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + directory_sync_enabled=False, + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_directory_sync_enabled.team_directory_sync_enabled import ( + team_directory_sync_enabled, + ) + + check = team_directory_sync_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have directory sync (SCIM) enabled. User provisioning and deprovisioning must be managed manually." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege_test.py b/tests/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege_test.py new file mode 100644 index 0000000000..f125715dbd --- /dev/null +++ b/tests/providers/vercel/services/team/team_member_role_least_privilege/team_member_role_least_privilege_test.py @@ -0,0 +1,266 @@ +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import ( + SAMLConfig, + VercelTeam, + VercelTeamMember, +) +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_member_role_least_privilege: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.teams = {} + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 0 + + def test_member_least_privilege(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id="member_1", + email="member@example.com", + role="MEMBER", + status="active", + ), + ], + ) + } + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has 0 owner(s) out of 1 active members. Small team with minimum required owner — least privilege threshold not applicable." + ) + assert result[0].team_id == "" + + def test_small_team_single_owner(self): + """Small team (<5 members) with 1 owner gets a PASS (small team exception).""" + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id="member_1", + email="member@example.com", + role="OWNER", + status="active", + ), + ], + ) + } + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has 1 owner(s) out of 1 active members. Small team with minimum required owner — least privilege threshold not applicable." + ) + assert result[0].team_id == "" + + def test_large_team_too_many_owners(self): + """Large team (>=5 members) with >20% owners gets a FAIL.""" + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id=f"member_{i}", + email=f"member{i}@example.com", + role="OWNER" if i <= 2 else "MEMBER", + status="active", + ) + for i in range(1, 6) + ], + ) + } + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert "40% exceeds the 20% threshold" in result[0].status_extended + assert result[0].team_id == "" + + def test_large_team_exceeds_max_owners(self): + """Large team within percentage but exceeding max_owners gets a FAIL.""" + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id=f"member_{i}", + email=f"member{i}@example.com", + role="OWNER" if i <= 4 else "MEMBER", + status="active", + ) + for i in range(1, 21) + ], + ) + } + team_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "4 owners exceeds the maximum of 3" in result[0].status_extended + + def test_custom_thresholds(self): + """Custom thresholds via audit_config are respected.""" + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[ + VercelTeamMember( + id=f"member_{i}", + email=f"member{i}@example.com", + role="OWNER" if i <= 2 else "MEMBER", + status="active", + ) + for i in range(1, 6) + ], + ) + } + team_client.audit_config = { + "max_owner_percentage": 50, + "max_owners": 5, + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_member_role_least_privilege.team_member_role_least_privilege import ( + team_member_role_least_privilege, + ) + + check = team_member_role_least_privilege() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "within the configured thresholds (50% / max 5 owners)" + in result[0].status_extended + ) diff --git a/tests/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations_test.py b/tests/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations_test.py new file mode 100644 index 0000000000..5ca777a53f --- /dev/null +++ b/tests/providers/vercel/services/team/team_no_stale_invitations/team_no_stale_invitations_test.py @@ -0,0 +1,130 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import ( + VercelTeam, + VercelTeamMember, +) +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_no_stale_invitations: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.audit_config = {"stale_invitation_threshold_days": 30} + team_client.teams = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations import ( + team_no_stale_invitations, + ) + + check = team_no_stale_invitations() + result = check.execute() + assert len(result) == 0 + + def test_no_stale_invitations(self): + team_client = mock.MagicMock + team_client.audit_config = {"stale_invitation_threshold_days": 30} + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + members=[ + VercelTeamMember( + id="member_1", + email="invited@example.com", + role="MEMBER", + status="invited", + created_at=datetime.now(timezone.utc) - timedelta(days=5), + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations import ( + team_no_stale_invitations, + ) + + check = team_no_stale_invitations() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has no stale pending invitations older than 30 days." + ) + assert result[0].team_id == "" + + def test_stale_invitation(self): + team_client = mock.MagicMock + team_client.audit_config = {"stale_invitation_threshold_days": 30} + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + members=[ + VercelTeamMember( + id="member_1", + email="old_invite@example.com", + role="MEMBER", + status="invited", + created_at=datetime.now(timezone.utc) - timedelta(days=60), + ), + ], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_no_stale_invitations.team_no_stale_invitations import ( + team_no_stale_invitations, + ) + + check = team_no_stale_invitations() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has 1 stale pending invitation(s) older than 30 days." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py b/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py new file mode 100644 index 0000000000..b7f40dd653 --- /dev/null +++ b/tests/providers/vercel/services/team/team_saml_sso_enabled/team_saml_sso_enabled_test.py @@ -0,0 +1,108 @@ +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import SAMLConfig, VercelTeam +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_saml_sso_enabled: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.teams = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled import ( + team_saml_sso_enabled, + ) + + check = team_saml_sso_enabled() + result = check.execute() + assert len(result) == 0 + + def test_saml_enabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="enabled", enforced=False), + members=[], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled import ( + team_saml_sso_enabled, + ) + + check = team_saml_sso_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended == f"Team {TEAM_NAME} has SAML SSO enabled." + ) + assert result[0].team_id == "" + + def test_saml_disabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + members=[], + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enabled.team_saml_sso_enabled import ( + team_saml_sso_enabled, + ) + + check = team_saml_sso_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have SAML SSO enabled." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py b/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py new file mode 100644 index 0000000000..360d6cdbd8 --- /dev/null +++ b/tests/providers/vercel/services/team/team_saml_sso_enforced/team_saml_sso_enforced_test.py @@ -0,0 +1,144 @@ +from unittest import mock + +from prowler.providers.vercel.services.team.team_service import SAMLConfig, VercelTeam +from tests.providers.vercel.vercel_fixtures import ( + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + set_mocked_vercel_provider, +) + + +class Test_team_saml_sso_enforced: + def test_no_teams(self): + team_client = mock.MagicMock + team_client.teams = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 0 + + def test_saml_enforced(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="enabled", enforced=True), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} enforces SAML SSO for all members." + ) + assert result[0].team_id == "" + + def test_saml_enabled_not_enforced(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="enabled", enforced=False), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} has SAML SSO enabled but does not enforce it. Members can still authenticate without SSO." + ) + assert result[0].team_id == "" + + def test_saml_disabled(self): + team_client = mock.MagicMock + team_client.teams = { + TEAM_ID: VercelTeam( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + saml=SAMLConfig(status="disabled", enforced=False), + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_vercel_provider(), + ), + mock.patch( + "prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced.team_client", + new=team_client, + ), + ): + from prowler.providers.vercel.services.team.team_saml_sso_enforced.team_saml_sso_enforced import ( + team_saml_sso_enforced, + ) + + check = team_saml_sso_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == TEAM_ID + assert result[0].resource_name == TEAM_NAME + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Team {TEAM_NAME} does not have SAML SSO enforced." + ) + assert result[0].team_id == "" diff --git a/tests/providers/vercel/vercel_fixtures.py b/tests/providers/vercel/vercel_fixtures.py new file mode 100644 index 0000000000..70775bb377 --- /dev/null +++ b/tests/providers/vercel/vercel_fixtures.py @@ -0,0 +1,59 @@ +from unittest.mock import MagicMock + +from prowler.providers.vercel.models import ( + VercelIdentityInfo, + VercelSession, + VercelTeamInfo, +) + +# Vercel Identity +TEAM_ID = "team_test123" +TEAM_NAME = "Test Team" +TEAM_SLUG = "test-team" +USER_ID = "user_test456" +USERNAME = "testuser" +USER_EMAIL = "test@example.com" + +# Vercel Credentials +API_TOKEN = "test-vercel-api-token" + +# Project Constants +PROJECT_ID = "prj_test789" +PROJECT_NAME = "my-test-project" + +# Domain Constants +DOMAIN_NAME = "example.com" + +# Deployment Constants +DEPLOYMENT_ID = "dpl_test012" + + +def set_mocked_vercel_provider( + api_token: str = API_TOKEN, + team_id: str = TEAM_ID, + identity: VercelIdentityInfo = None, + audit_config: dict = None, +): + """Create a mocked VercelProvider for testing.""" + provider = MagicMock() + provider.type = "vercel" + provider.session = VercelSession( + token=api_token, + team_id=team_id, + http_session=MagicMock(), + ) + provider.identity = identity or VercelIdentityInfo( + user_id=USER_ID, + username=USERNAME, + email=USER_EMAIL, + team=VercelTeamInfo( + id=TEAM_ID, + name=TEAM_NAME, + slug=TEAM_SLUG, + ), + ) + provider.audit_config = audit_config or {"max_retries": 3} + provider.fixer_config = {} + provider.filter_projects = None + + return provider diff --git a/tests/providers/vercel/vercel_provider_test.py b/tests/providers/vercel/vercel_provider_test.py new file mode 100644 index 0000000000..56117ae6c4 --- /dev/null +++ b/tests/providers/vercel/vercel_provider_test.py @@ -0,0 +1,224 @@ +import os +from unittest import mock +from unittest.mock import MagicMock, patch + +import pytest + +from prowler.providers.common.models import Connection +from prowler.providers.vercel.exceptions.exceptions import ( + VercelAuthenticationError, + VercelCredentialsError, +) +from prowler.providers.vercel.models import VercelIdentityInfo, VercelSession +from prowler.providers.vercel.vercel_provider import VercelProvider +from tests.providers.vercel.vercel_fixtures import ( + API_TOKEN, + TEAM_ID, + TEAM_NAME, + TEAM_SLUG, + USER_EMAIL, + USER_ID, + USERNAME, +) + + +class TestVercelProviderSetupSession: + def test_setup_session_with_env_var(self): + with mock.patch.dict(os.environ, {"VERCEL_TOKEN": API_TOKEN}, clear=False): + session = VercelProvider.setup_session() + + assert isinstance(session, VercelSession) + assert session.token == API_TOKEN + assert session.http_session is not None + + def test_setup_session_with_api_token_param(self): + with mock.patch.dict(os.environ, {}, clear=True): + session = VercelProvider.setup_session(api_token=API_TOKEN) + + assert isinstance(session, VercelSession) + assert session.token == API_TOKEN + assert session.http_session is not None + + def test_setup_session_with_team_id_param(self): + with mock.patch.dict(os.environ, {}, clear=True): + session = VercelProvider.setup_session(api_token=API_TOKEN, team_id=TEAM_ID) + + assert session.token == API_TOKEN + assert session.team_id == TEAM_ID + + def test_setup_session_no_credentials_raises(self): + with mock.patch.dict(os.environ, {}, clear=True): + os.environ.pop("VERCEL_TOKEN", None) + with pytest.raises(VercelCredentialsError): + VercelProvider.setup_session() + + def test_setup_session_team_from_env(self): + with mock.patch.dict( + os.environ, {"VERCEL_TOKEN": API_TOKEN, "VERCEL_TEAM": TEAM_ID} + ): + session = VercelProvider.setup_session() + + assert session.team_id == TEAM_ID + + +class TestVercelProviderSetupIdentity: + def test_setup_identity_with_team(self): + mock_session = VercelSession( + token=API_TOKEN, team_id=TEAM_ID, http_session=MagicMock() + ) + + # Mock user response + user_response = MagicMock() + user_response.status_code = 200 + user_response.json.return_value = { + "user": { + "id": USER_ID, + "username": USERNAME, + "email": USER_EMAIL, + } + } + user_response.raise_for_status = MagicMock() + + # Mock team response + team_response = MagicMock() + team_response.status_code = 200 + team_response.json.return_value = { + "id": TEAM_ID, + "name": TEAM_NAME, + "slug": TEAM_SLUG, + } + team_response.raise_for_status = MagicMock() + + def mock_get(url, **kwargs): + if "/v2/user" in url: + return user_response + if f"/v2/teams/{TEAM_ID}" in url: + return team_response + return MagicMock() + + mock_session.http_session.get = mock_get + + identity = VercelProvider.setup_identity(mock_session) + + assert isinstance(identity, VercelIdentityInfo) + assert identity.user_id == USER_ID + assert identity.username == USERNAME + assert identity.email == USER_EMAIL + assert identity.team is not None + assert identity.team.id == TEAM_ID + assert identity.team.name == TEAM_NAME + assert identity.team.slug == TEAM_SLUG + + def test_setup_identity_personal_account(self): + mock_session = VercelSession( + token=API_TOKEN, team_id=None, http_session=MagicMock() + ) + + user_response = MagicMock() + user_response.status_code = 200 + user_response.json.return_value = { + "user": { + "id": USER_ID, + "username": USERNAME, + "email": USER_EMAIL, + } + } + user_response.raise_for_status = MagicMock() + + mock_session.http_session.get = MagicMock(return_value=user_response) + + identity = VercelProvider.setup_identity(mock_session) + + assert identity.team is None + assert identity.user_id == USER_ID + + +class TestVercelProviderValidateCredentials: + def test_valid_credentials(self): + mock_session = VercelSession( + token=API_TOKEN, team_id=None, http_session=MagicMock() + ) + + response = MagicMock() + response.status_code = 200 + response.raise_for_status = MagicMock() + mock_session.http_session.get = MagicMock(return_value=response) + + # Should not raise + VercelProvider.validate_credentials(mock_session) + + def test_invalid_token_raises(self): + mock_session = VercelSession( + token="invalid", team_id=None, http_session=MagicMock() + ) + + response = MagicMock() + response.status_code = 401 + mock_session.http_session.get = MagicMock(return_value=response) + + with pytest.raises(VercelAuthenticationError): + VercelProvider.validate_credentials(mock_session) + + def test_forbidden_raises(self): + mock_session = VercelSession( + token=API_TOKEN, team_id=None, http_session=MagicMock() + ) + + response = MagicMock() + response.status_code = 403 + mock_session.http_session.get = MagicMock(return_value=response) + + with pytest.raises(VercelAuthenticationError): + VercelProvider.validate_credentials(mock_session) + + +class TestVercelProviderTestConnection: + @patch.object(VercelProvider, "validate_credentials") + @patch.object(VercelProvider, "setup_session") + def test_successful_connection(self, mock_setup_session, mock_validate): + mock_setup_session.return_value = VercelSession( + token=API_TOKEN, team_id=None, http_session=MagicMock() + ) + mock_validate.return_value = None + + result = VercelProvider.test_connection(raise_on_exception=False) + + assert isinstance(result, Connection) + assert result.is_connected is True + + @patch.object(VercelProvider, "validate_credentials") + @patch.object(VercelProvider, "setup_session") + def test_successful_connection_with_params(self, mock_setup_session, mock_validate): + mock_setup_session.return_value = VercelSession( + token=API_TOKEN, team_id=TEAM_ID, http_session=MagicMock() + ) + mock_validate.return_value = None + + result = VercelProvider.test_connection( + api_token=API_TOKEN, team_id=TEAM_ID, raise_on_exception=False + ) + + assert isinstance(result, Connection) + assert result.is_connected is True + mock_setup_session.assert_called_once_with(api_token=API_TOKEN, team_id=TEAM_ID) + + @patch.object(VercelProvider, "setup_session") + def test_failed_connection_no_credentials(self, mock_setup_session): + mock_setup_session.side_effect = VercelCredentialsError( + message="No credentials" + ) + + result = VercelProvider.test_connection(raise_on_exception=False) + + assert isinstance(result, Connection) + assert result.is_connected is False + assert result.error is not None + + @patch.object(VercelProvider, "setup_session") + def test_failed_connection_raises(self, mock_setup_session): + mock_setup_session.side_effect = VercelCredentialsError( + message="No credentials" + ) + + with pytest.raises(VercelCredentialsError): + VercelProvider.test_connection(raise_on_exception=True) diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit index d136d9ec54..98be29c453 100755 --- a/ui/.husky/pre-commit +++ b/ui/.husky/pre-commit @@ -38,7 +38,8 @@ echo -e "${BLUE}ℹ️ Code Review Status: ${CODE_REVIEW_ENABLED}${NC}" echo "" # Get staged files in the UI folder only (what will be committed) -STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM -- 'ui/**' | grep -E '\.(tsx?|jsx?)$' || true) +# Always use GIT_ROOT-relative pathspecs so detection works regardless of cwd +STAGED_FILES=$(git -C "$GIT_ROOT" diff --cached --name-only --diff-filter=ACM -- 'ui/' | grep -E '\.(tsx?|jsx?)$' || true) if [ "$CODE_REVIEW_ENABLED" = "true" ]; then if [ -z "$STAGED_FILES" ]; then @@ -135,18 +136,17 @@ else echo "" fi -# Check if there are any UI files to validate -if [ -z "$STAGED_FILES" ] && [ "$CODE_REVIEW_ENABLED" = "true" ]; then - echo -e "${YELLOW}⏭️ No UI files to validate, skipping healthcheck${NC}" +# Run healthcheck (typecheck and lint check) only if there are UI changes +if [ -z "$STAGED_FILES" ]; then + echo -e "${YELLOW}⏭️ No UI files staged, skipping healthcheck/tests/build${NC}" echo "" exit 0 fi -# Run healthcheck (typecheck and lint check) only if there are UI changes echo -e "${BLUE}🏥 Running healthcheck...${NC}" echo "" -cd ui || cd . +cd "$GIT_ROOT/ui" if pnpm run healthcheck; then echo "" echo -e "${GREEN}✅ Healthcheck passed${NC}" @@ -164,11 +164,11 @@ echo -e "${BLUE}🧪 Running unit tests...${NC}" echo "" # Get staged source files (exclude test files) -# Note: we already cd'd into ui/, so pathspecs are relative (no ui/ prefix) -STAGED_SOURCE_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '*.ts' '*.tsx' | grep -v '\.test\.\|\.spec\.\|vitest\.config\|vitest\.setup' || true) +# Use GIT_ROOT so pathspecs are always correct regardless of cwd +STAGED_SOURCE_FILES=$(git -C "$GIT_ROOT" diff --cached --name-only --diff-filter=ACM -- 'ui/*.ts' 'ui/*.tsx' | sed 's|^ui/||' | grep -v '\.test\.\|\.spec\.\|vitest\.config\|vitest\.setup' || true) # Check if critical paths changed (lib/, types/, config/) -CRITICAL_PATHS_CHANGED=$(git diff --cached --name-only -- 'lib/**' 'types/**' 'config/**' 'middleware.ts' 'vitest.config.ts' 'vitest.setup.ts' || true) +CRITICAL_PATHS_CHANGED=$(git -C "$GIT_ROOT" diff --cached --name-only -- 'ui/lib/' 'ui/types/' 'ui/config/' 'ui/middleware.ts' 'ui/vitest.config.ts' 'ui/vitest.setup.ts' || true) if [ -n "$CRITICAL_PATHS_CHANGED" ]; then echo -e "${YELLOW}Critical paths changed - running ALL unit tests${NC}" diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 1db070175d..941e7c5ff4 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,89 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.23.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Multi-tenant organization management: create, switch, edit, and delete organizations from the profile page [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) +- Findings grouped view with drill-down table showing resources per check, resource detail drawer, infinite scroll pagination, and bulk mute support [(#10425)](https://github.com/prowler-cloud/prowler/pull/10425) +- Resource events tool to Lighthouse AI [(#10412)](https://github.com/prowler-cloud/prowler/pull/10412) +- Vercel provider: connect Vercel teams via API token, scan deployments, domains, projects, and team settings [(#10191)](https://github.com/prowler-cloud/prowler/pull/10191) + +### 🔄 Changed + +- Attack Paths custom openCypher queries now use a code editor with syntax highlighting and line numbers [(#10445)](https://github.com/prowler-cloud/prowler/pull/10445) +- Filter summary strip: removed redundant "Clear all" link next to pills (use top-bar Clear Filters instead) and switched chip variant from `outline` to `tag` for consistency [(#10481)](https://github.com/prowler-cloud/prowler/pull/10481) + +### 🐞 Fixed + +- Deleting the active organization now switches to the target org before deleting, preventing JWT rejection from the backend [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) +- Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446) +- Send to Jira modal now dynamically fetches and displays available issue types per project instead of hardcoding `"Task"`, fixing failures on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) + +--- + +## [1.22.0] (Prowler v5.22.0) + +### 🚀 Added + +- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397) + +### 🔄 Changed + +- Findings filters now use a batch-apply pattern with an Apply Filters button, filter summary strip, and independent filter options instead of triggering API calls on every selection [(#10388)](https://github.com/prowler-cloud/prowler/pull/10388) + +--- + +## [1.21.0] (Prowler v5.21.0) + +### 🚀 Added + +- Attack Paths custom openCypher queries with Cartography schema guidance and clearer execution errors [(#10397)](https://github.com/prowler-cloud/prowler/pull/10397) + +--- + +## [1.21.0] (Prowler v5.21.0) + +### 🚀 Added + +- Skill system to Lighthouse AI [(#10322)](https://github.com/prowler-cloud/prowler/pull/10322) +- Skill for creating custom queries on Attack Paths [(#10323)](https://github.com/prowler-cloud/prowler/pull/10323) + +### 🔄 Changed + +- Google Workspace provider support [(#10333)](https://github.com/prowler-cloud/prowler/pull/10333) +- Image (Container Registry) provider support in UI: badge icon, credentials form, and provider-type filtering [(#10167)](https://github.com/prowler-cloud/prowler/pull/10167) +- Events tab in Findings and Resource detail cards showing an AWS CloudTrail timeline with expandable event rows, actor info, request/response JSON payloads, and error details [(#10320)](https://github.com/prowler-cloud/prowler/pull/10320) +- AWS Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317) + +--- + +## [1.20.0] (Prowler v5.20.0) + +### 🚀 Added + +- Mute button in the finding detailed view, allowing users to mute findings directly without going back to the table [(#10303)](https://github.com/prowler-cloud/prowler/pull/10303) + +### 🔄 Changed + +- Attack Paths: Improved error handling for server errors (5xx) and network failures with user-friendly messages instead of raw internal errors and layout changes [(#10249)](https://github.com/prowler-cloud/prowler/pull/10249) +- Refactor simple providers with new components and styles [(#10259)](https://github.com/prowler-cloud/prowler/pull/10259) +- Providers page redesigned with cloud organization hierarchy, HeroUI-to-shadcn migration, organization and account group filters, and row selection for bulk actions [(#10292)](https://github.com/prowler-cloud/prowler/pull/10292) +- AWS Organizations onboarding now uses a clearer 3-step flow: deploy the ProwlerScan role in the management account via CloudFormation Stack, deploy to member accounts via StackSet with a copyable template URL, and confirm with the Role ARN [(#10274)](https://github.com/prowler-cloud/prowler/pull/10274) + +### 🐞 Fixed + +- Provider wizard now closes after updating credentials instead of incorrectly advancing to the Launch Scan step, which caused API errors for providers with existing scheduled scans [(#10278)](https://github.com/prowler-cloud/prowler/pull/10278) +- Attack Paths query builder sending stale parameters from previous query selections due to validation schema and default values being recreated on every render [(#10306)](https://github.com/prowler-cloud/prowler/pull/10306) +- Finding detail drawer crashing when resource, scan, or provider relationships are missing from the API response [(#10314)](https://github.com/prowler-cloud/prowler/pull/10314) + +### 🔐 Security + +- npm transitive dependencies patched to resolve 11 Dependabot alerts (6 HIGH, 4 MEDIUM, 1 LOW): hono, @hono/node-server, fast-xml-parser, serialize-javascript, minimatch [(#10267)](https://github.com/prowler-cloud/prowler/pull/10267) + +--- + ## [1.19.0] (Prowler v5.19.0) ### 🚀 Added diff --git a/ui/Dockerfile b/ui/Dockerfile index bad77dae1f..9163be77cc 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,9 +1,9 @@ -FROM node:24.13.0-alpine AS base +FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b181aa49e22c14383e AS base LABEL maintainer="https://github.com/prowler-cloud" # Enable corepack for pnpm -RUN corepack enable && corepack prepare pnpm@10 --activate +RUN corepack enable # Install dependencies only when needed FROM base AS deps @@ -13,9 +13,9 @@ RUN apk add --no-cache libc6-compat WORKDIR /app # Install dependencies based on the preferred package manager -COPY package.json pnpm-lock.yaml .npmrc ./ +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ COPY scripts ./scripts -RUN pnpm install --frozen-lockfile +RUN corepack install && pnpm install --frozen-lockfile # Rebuild the source code only when needed @@ -23,6 +23,10 @@ FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +# Install pinned pnpm so build uses the exact version from package.json. +# Alternative: move COPY package.json + corepack install to base stage to avoid +# re-downloading, at the cost of invalidating all stages on any package.json change. +RUN corepack install # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry diff --git a/ui/actions/attack-paths/queries.adapter.test.ts b/ui/actions/attack-paths/queries.adapter.test.ts new file mode 100644 index 0000000000..cd6bafd206 --- /dev/null +++ b/ui/actions/attack-paths/queries.adapter.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + ATTACK_PATH_QUERY_IDS, + type AttackPathCartographySchemaAttributes, + type AttackPathQuery, +} from "@/types/attack-paths"; + +import { buildAttackPathQueries } from "./queries.adapter"; + +const presetQuery: AttackPathQuery = { + type: "attack-paths-scans", + id: "preset-query", + attributes: { + name: "Preset Query", + short_description: "Returns privileged attack paths", + description: "Returns privileged attack paths.", + provider: "aws", + attribution: null, + parameters: [], + }, +}; + +describe("buildAttackPathQueries", () => { + it("prepends a custom query with a schema documentation link", () => { + // Given + const schema: AttackPathCartographySchemaAttributes = { + id: "aws-0.129.0", + provider: "aws", + cartography_version: "0.129.0", + schema_url: + "https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md", + raw_schema_url: + "https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md", + }; + + // When + const result = buildAttackPathQueries([presetQuery], schema); + + // Then + expect(result[0]).toMatchObject({ + id: ATTACK_PATH_QUERY_IDS.CUSTOM, + attributes: { + name: "Custom openCypher query", + short_description: "Write and run your own read-only query", + documentation_link: { + text: "Cartography schema used by Prowler for AWS graphs", + link: schema.schema_url, + }, + }, + }); + expect(result[1]).toEqual(presetQuery); + }); +}); diff --git a/ui/actions/attack-paths/queries.adapter.ts b/ui/actions/attack-paths/queries.adapter.ts index fd256739e1..0b3120bb0a 100644 --- a/ui/actions/attack-paths/queries.adapter.ts +++ b/ui/actions/attack-paths/queries.adapter.ts @@ -1,7 +1,10 @@ import { MetaDataProps } from "@/types"; import { + ATTACK_PATH_QUERY_IDS, + type AttackPathCartographySchemaAttributes, AttackPathQueriesResponse, AttackPathQuery, + QUERY_PARAMETER_INPUT_TYPES, } from "@/types/attack-paths"; /** @@ -53,3 +56,54 @@ export function adaptAttackPathQueriesResponse( return { data: enrichedData, metadata }; } + +const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n) +RETURN n +LIMIT 25`; + +const formatSchemaDocumentationLinkText = ( + schema: AttackPathCartographySchemaAttributes, +): string => { + return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`; +}; + +const createCustomQuery = ( + schema?: AttackPathCartographySchemaAttributes, +): AttackPathQuery => ({ + type: "attack-paths-scans", + id: ATTACK_PATH_QUERY_IDS.CUSTOM, + attributes: { + name: "Custom openCypher query", + short_description: "Write and run your own read-only query", + description: + "Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.", + provider: "custom", + attribution: null, + documentation_link: schema + ? { + text: formatSchemaDocumentationLinkText(schema), + link: schema.schema_url, + } + : null, + parameters: [ + { + name: "query", + label: "openCypher", + data_type: "string", + description: "", + placeholder: CUSTOM_QUERY_PLACEHOLDER, + required: true, + input_type: QUERY_PARAMETER_INPUT_TYPES.CODE_EDITOR, + editor_language: "openCypher", + requirement_badge: "Read-only*", + }, + ], + }, +}); + +export const buildAttackPathQueries = ( + queries: AttackPathQuery[], + schema?: AttackPathCartographySchemaAttributes, +): AttackPathQuery[] => { + return [createCustomQuery(schema), ...queries]; +}; diff --git a/ui/actions/attack-paths/queries.test.ts b/ui/actions/attack-paths/queries.test.ts new file mode 100644 index 0000000000..ab3afc447f --- /dev/null +++ b/ui/actions/attack-paths/queries.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +import { + executeCustomQuery, + executeQuery, + getCartographySchema, +} from "./queries"; + +describe("executeQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("returns a friendly message when API response handling throws", async () => { + // Given + fetchMock.mockResolvedValue( + new Response(null, { + status: 500, + }), + ); + handleApiResponseMock.mockRejectedValue( + new Error("Server error (500): backend database unavailable"), + ); + + // When + const result = await executeQuery( + "550e8400-e29b-41d4-a716-446655440000", + "aws-iam-statements-allow-all-actions", + ); + + // Then + expect(handleApiResponseMock).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + status: 503, + }); + }); + + it("returns undefined and skips fetch for invalid scan ids", async () => { + // When + const result = await executeQuery( + "not-a-uuid", + "aws-iam-statements-allow-all-actions", + ); + + // Then + expect(result).toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); +}); + +describe("executeCustomQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ + data: { + type: "attack-paths-query-run-requests", + id: null, + attributes: { + nodes: [], + relationships: [], + }, + }, + }); + }); + + it("posts the custom query to the dedicated endpoint", async () => { + // Given + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + + // When + await executeCustomQuery( + "550e8400-e29b-41d4-a716-446655440000", + "MATCH (n) RETURN n LIMIT 10", + ); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/queries/custom", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + data: { + type: "attack-paths-custom-query-run-requests", + attributes: { + query: "MATCH (n) RETURN n LIMIT 10", + }, + }, + }), + }), + ); + }); + + it("rejects empty custom queries before calling the API", async () => { + // When + const result = await executeCustomQuery( + "550e8400-e29b-41d4-a716-446655440000", + " ", + ); + + // Then + expect(result).toEqual({ + error: "Custom query cannot be empty", + status: 400, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); + + it("rejects custom queries longer than 10000 characters before calling the API", async () => { + // When + const result = await executeCustomQuery( + "550e8400-e29b-41d4-a716-446655440000", + "x".repeat(10001), + ); + + // Then + expect(result).toEqual({ + error: "Custom query must be 10000 characters or fewer", + status: 400, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); + + it("rejects custom queries with write operations before calling the API", async () => { + // When + const result = await executeCustomQuery( + "550e8400-e29b-41d4-a716-446655440000", + "MATCH (n) SET n.name = 'updated' RETURN n", + ); + + // Then + expect(result).toEqual({ + error: "Only read-only queries are allowed", + status: 400, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); +}); + +describe("getCartographySchema", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("fetches the schema metadata for the selected scan", async () => { + // Given + const apiResponse = { + data: { + type: "attack-paths-cartography-schemas", + id: "aws-0.129.0", + attributes: { + id: "aws-0.129.0", + provider: "aws", + cartography_version: "0.129.0", + schema_url: + "https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md", + raw_schema_url: + "https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md", + }, + }, + }; + fetchMock.mockResolvedValue(new Response(null, { status: 200 })); + handleApiResponseMock.mockResolvedValue(apiResponse); + + // When + const result = await getCartographySchema( + "550e8400-e29b-41d4-a716-446655440000", + ); + + // Then + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/attack-paths-scans/550e8400-e29b-41d4-a716-446655440000/schema", + expect.objectContaining({ + method: "GET", + }), + ); + expect(result).toEqual(apiResponse); + }); +}); diff --git a/ui/actions/attack-paths/queries.ts b/ui/actions/attack-paths/queries.ts index 0332e6ec22..228c1cca0c 100644 --- a/ui/actions/attack-paths/queries.ts +++ b/ui/actions/attack-paths/queries.ts @@ -3,11 +3,16 @@ import { z } from "zod"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { customAttackPathQuerySchema } from "@/lib/attack-paths/custom-query"; import { handleApiResponse } from "@/lib/server-actions-helper"; import { + AttackPathCartographySchema, + AttackPathCartographySchemaResponse, AttackPathQueriesResponse, AttackPathQuery, + AttackPathQueryError, AttackPathQueryResult, + ExecuteCustomQueryRequest, ExecuteQueryRequest, } from "@/types/attack-paths"; @@ -59,7 +64,7 @@ export const executeQuery = async ( scanId: string, queryId: string, parameters?: Record, -): Promise => { +): Promise => { // Validate scanId is a valid UUID format to prevent request forgery const validatedScanId = UUIDSchema.safeParse(scanId); if (!validatedScanId.success) { @@ -89,9 +94,105 @@ export const executeQuery = async ( }, ); - return handleApiResponse(response); + return (await handleApiResponse(response)) as + | AttackPathQueryResult + | AttackPathQueryError; } catch (error) { console.error("Error executing query on scan:", error); + return { + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + status: 503, + }; + } +}; + +/** + * Execute a custom openCypher query on an attack path scan + */ +export const executeCustomQuery = async ( + scanId: string, + query: string, +): Promise => { + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const validatedQuery = customAttackPathQuerySchema.safeParse(query); + if (!validatedQuery.success) { + return { + error: + validatedQuery.error.issues[0]?.message ?? "Custom query is invalid.", + status: 400, + }; + } + + const headers = await getAuthHeaders({ contentType: true }); + + const requestBody: ExecuteCustomQueryRequest = { + data: { + type: "attack-paths-custom-query-run-requests", + attributes: { + query: validatedQuery.data, + }, + }, + }; + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/queries/custom`, + { + headers, + method: "POST", + body: JSON.stringify(requestBody), + }, + ); + + return (await handleApiResponse(response)) as + | AttackPathQueryResult + | AttackPathQueryError; + } catch (error) { + console.error("Error executing custom query on scan:", error); + return { + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + status: 503, + }; + } +}; + +/** + * Fetch cartography schema metadata for a specific attack path scan + */ +export const getCartographySchema = async ( + scanId: string, +): Promise<{ data: AttackPathCartographySchema } | undefined> => { + const validatedScanId = UUIDSchema.safeParse(scanId); + if (!validatedScanId.success) { + console.error("Invalid scan ID format"); + return undefined; + } + + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch( + `${apiBaseUrl}/attack-paths-scans/${validatedScanId.data}/schema`, + { + headers, + method: "GET", + }, + ); + + const apiResponse = (await handleApiResponse( + response, + )) as AttackPathCartographySchemaResponse; + + return { data: apiResponse.data }; + } catch (error) { + console.error("Error fetching cartography schema for scan:", error); return undefined; } }; diff --git a/ui/actions/attack-paths/scans.adapter.ts b/ui/actions/attack-paths/scans.adapter.ts index a8236241a3..e6ef4168e2 100644 --- a/ui/actions/attack-paths/scans.adapter.ts +++ b/ui/actions/attack-paths/scans.adapter.ts @@ -1,3 +1,4 @@ +import { formatDuration } from "@/lib/date-utils"; import { MetaDataProps } from "@/types"; import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths"; @@ -61,18 +62,6 @@ export function adaptAttackPathScansResponse( return { data: enrichedData, metadata }; } -/** - * Format duration in seconds to human-readable format - * - * @param seconds - Duration in seconds - * @returns Formatted duration string (e.g., "2m 30s") - */ -function formatDuration(seconds: number): string { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}m ${remainingSeconds}s`; -} - /** * Check if a scan is recent (completed within last 24 hours) * diff --git a/ui/actions/finding-groups/finding-groups.adapter.test.ts b/ui/actions/finding-groups/finding-groups.adapter.test.ts new file mode 100644 index 0000000000..5874ddf7e5 --- /dev/null +++ b/ui/actions/finding-groups/finding-groups.adapter.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; + +import { + adaptFindingGroupResourcesResponse, + adaptFindingGroupsResponse, +} from "./finding-groups.adapter"; + +// --------------------------------------------------------------------------- +// Fix 1: adaptFindingGroupsResponse — unknown + type guard +// --------------------------------------------------------------------------- + +describe("adaptFindingGroupsResponse — malformed input", () => { + it("should return [] when apiResponse is null", () => { + // Given + const input = null; + + // When + const result = adaptFindingGroupsResponse(input); + + // Then + 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" }; + + // When + const result = adaptFindingGroupsResponse(input); + + // Then + 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 = { + data: [ + { + id: "group-1", + type: "finding-groups", + attributes: { + check_id: "s3_bucket_public_access", + check_title: "S3 Bucket Public Access", + check_description: null, + severity: "critical", + status: "FAIL", + impacted_providers: ["aws"], + resources_total: 5, + resources_fail: 3, + pass_count: 2, + fail_count: 3, + muted_count: 0, + new_count: 1, + changed_count: 0, + first_seen_at: null, + last_seen_at: "2024-01-01T00:00:00Z", + failing_since: null, + }, + }, + ], + }; + + // When + const result = adaptFindingGroupsResponse(input); + + // Then + expect(result).toHaveLength(1); + expect(result[0].checkId).toBe("s3_bucket_public_access"); + expect(result[0].checkTitle).toBe("S3 Bucket Public Access"); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 1: adaptFindingGroupResourcesResponse — unknown + type guard +// --------------------------------------------------------------------------- + +describe("adaptFindingGroupResourcesResponse — malformed input", () => { + it("should return [] when apiResponse is null", () => { + // Given/When + const result = adaptFindingGroupResourcesResponse(null, "check-1"); + + // Then + 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"); + + // Then + expect(result).toEqual([]); + }); + + it("should return [] when apiResponse is undefined", () => { + // Given/When + const result = adaptFindingGroupResourcesResponse(undefined, "check-1"); + + // Then + expect(result).toEqual([]); + }); + + it("should return mapped rows for valid data", () => { + // Given + const input = { + data: [ + { + id: "resource-row-1", + type: "finding-group-resources", + attributes: { + 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: "critical", + first_seen_at: null, + last_seen_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }; + + // When + const result = adaptFindingGroupResourcesResponse(input, "s3_check"); + + // Then + expect(result).toHaveLength(1); + expect(result[0].checkId).toBe("s3_check"); + expect(result[0].resourceName).toBe("my-bucket"); + }); +}); diff --git a/ui/actions/finding-groups/finding-groups.adapter.ts b/ui/actions/finding-groups/finding-groups.adapter.ts new file mode 100644 index 0000000000..593171078d --- /dev/null +++ b/ui/actions/finding-groups/finding-groups.adapter.ts @@ -0,0 +1,152 @@ +import type { + FindingGroupRow, + FindingResourceRow, + FindingStatus, + ProviderType, + Severity, +} from "@/types"; +import { FINDINGS_ROW_TYPE } from "@/types"; + +/** + * API response shape for a finding group (JSON:API). + * Each group represents a unique check_id with aggregated counts. + * + * Fields come from FindingGroupSerializer which aggregates + * FindingGroupDailySummary rows by check_id. + */ +interface FindingGroupAttributes { + check_id: string; + check_title: string | null; + check_description: string | null; + severity: string; + status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase) + impacted_providers: string[]; + resources_total: number; + resources_fail: number; + pass_count: number; + fail_count: number; + muted_count: number; + new_count: number; + changed_count: number; + first_seen_at: string | null; + last_seen_at: string | null; + failing_since: string | null; +} + +interface FindingGroupApiItem { + type: "finding-groups"; + id: string; + attributes: FindingGroupAttributes; +} + +/** + * Transforms the API response for finding groups into FindingGroupRow[]. + */ +export function adaptFindingGroupsResponse( + apiResponse: unknown, +): FindingGroupRow[] { + if ( + !apiResponse || + typeof apiResponse !== "object" || + !("data" in apiResponse) || + !Array.isArray((apiResponse as { data: unknown }).data) + ) { + return []; + } + + const data = (apiResponse as { data: FindingGroupApiItem[] }).data; + return data.map((item) => ({ + id: item.id, + rowType: FINDINGS_ROW_TYPE.GROUP, + checkId: item.attributes.check_id, + checkTitle: item.attributes.check_title || item.attributes.check_id, + severity: item.attributes.severity as Severity, + status: item.attributes.status as FindingStatus, + resourcesTotal: item.attributes.resources_total, + resourcesFail: item.attributes.resources_fail, + newCount: item.attributes.new_count, + changedCount: item.attributes.changed_count, + mutedCount: item.attributes.muted_count, + providers: (item.attributes.impacted_providers || []) as ProviderType[], + updatedAt: item.attributes.last_seen_at || "", + })); +} + +/** + * API response shape for a finding group resource (drill-down). + * Endpoint: /finding-groups/{check_id}/resources + * + * Each item has nested `resource` and `provider` objects in attributes + * (NOT JSON:API included — it's a custom serializer). + */ +interface ResourceInfo { + uid: string; + name: string; + service: string; + region: string; + type: string; + resource_group: string; +} + +interface ProviderInfo { + type: string; + uid: string; + alias: string; +} + +interface FindingGroupResourceAttributes { + resource: ResourceInfo; + provider: ProviderInfo; + status: string; + severity: string; + first_seen_at: string | null; + last_seen_at: string | null; + muted_reason?: string | null; +} + +interface FindingGroupResourceApiItem { + type: "finding-group-resources"; + id: string; + attributes: FindingGroupResourceAttributes; +} + +/** + * Transforms the API response for finding group resources (drill-down) + * into FindingResourceRow[]. + */ +export function adaptFindingGroupResourcesResponse( + apiResponse: unknown, + checkId: string, +): FindingResourceRow[] { + if ( + !apiResponse || + typeof apiResponse !== "object" || + !("data" in apiResponse) || + !Array.isArray((apiResponse as { data: unknown }).data) + ) { + return []; + } + + const data = (apiResponse as { data: FindingGroupResourceApiItem[] }).data; + return data.map((item) => ({ + id: item.id, + rowType: FINDINGS_ROW_TYPE.RESOURCE, + findingId: item.id, + checkId, + providerType: (item.attributes.provider?.type || "aws") as ProviderType, + providerAlias: item.attributes.provider?.alias || "", + providerUid: item.attributes.provider?.uid || "", + resourceName: item.attributes.resource?.name || "-", + resourceGroup: item.attributes.resource?.resource_group || "-", + resourceUid: item.attributes.resource?.uid || "-", + service: item.attributes.resource?.service || "-", + region: item.attributes.resource?.region || "-", + severity: (item.attributes.severity || "informational") as Severity, + status: item.attributes.status, + isMuted: item.attributes.status === "MUTED", + // TODO: remove fallback once the API returns muted_reason in finding-group-resources + mutedReason: item.attributes.muted_reason || undefined, + firstSeenAt: item.attributes.first_seen_at, + lastSeenAt: item.attributes.last_seen_at, + })); +} diff --git a/ui/actions/finding-groups/finding-groups.test.ts b/ui/actions/finding-groups/finding-groups.test.ts new file mode 100644 index 0000000000..0d8c2df53a --- /dev/null +++ b/ui/actions/finding-groups/finding-groups.test.ts @@ -0,0 +1,385 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Hoisted mocks (must be declared before any imports that need them) +// --------------------------------------------------------------------------- + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/provider-filters", () => ({ + // Simulate real appendSanitizedProviderFilters: appends all non-undefined filters to the URL. + appendSanitizedProviderFilters: vi.fn( + (url: URL, filters: Record) => { + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, String(value)); + } + }); + }, + ), +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Imports (after vi.mock declarations) +// --------------------------------------------------------------------------- + +import { + getFindingGroupResources, + getLatestFindingGroupResources, +} from "./finding-groups"; + +// --------------------------------------------------------------------------- +// Blocker 1 + 2: FAIL-first sort and FAIL-only filter for drill-down resources +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("getFindingGroupResources — SSRF path traversal protection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should encode a normal checkId without alteration", async () => { + // Given + const checkId = "s3_bucket_public_access"; + + // When + await getFindingGroupResources({ checkId }); + + // Then — URL path must contain encoded checkId, not raw + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain( + `/api/v1/finding-groups/${encodeURIComponent(checkId)}/resources`, + ); + }); + + it("should encode a checkId containing a forward slash (path traversal attempt)", async () => { + // Given — checkId with embedded slash: attacker attempts path traversal + const maliciousCheckId = "../../admin/secret"; + + // When + await getFindingGroupResources({ checkId: maliciousCheckId }); + + // Then — the URL must NOT contain a raw slash from the checkId + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + // The path should NOT end in /resources with traversal segments between + expect(url.pathname).not.toContain("/admin/secret/resources"); + // The encoded checkId must appear in the path + expect(url.pathname).toContain( + `/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`, + ); + }); + + it("should encode a checkId containing %2F (URL-encoded slash traversal attempt)", async () => { + // Given — checkId with %2F: double-encoding traversal attempt + const maliciousCheckId = "foo%2Fbar"; + + // When + await getFindingGroupResources({ checkId: maliciousCheckId }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.pathname).toContain( + `/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`, + ); + expect(url.pathname).not.toContain("/foo/bar/resources"); + }); + + it("should encode a checkId containing special chars like ? and #", async () => { + // Given + const maliciousCheckId = "check?admin=true#fragment"; + + // When + await getFindingGroupResources({ checkId: maliciousCheckId }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).not.toContain("?admin=true"); + expect(calledUrl.split("?")[0]).toContain( + `/finding-groups/${encodeURIComponent(maliciousCheckId)}/resources`, + ); + }); +}); + +describe("getLatestFindingGroupResources — SSRF path traversal protection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should encode a normal checkId without alteration", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + + // When + await getLatestFindingGroupResources({ checkId }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain( + `/api/v1/finding-groups/latest/${encodeURIComponent(checkId)}/resources`, + ); + }); + + it("should encode a checkId containing a forward slash in the latest endpoint", async () => { + // Given + const maliciousCheckId = "../other-endpoint"; + + // When + await getLatestFindingGroupResources({ checkId: maliciousCheckId }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.pathname).not.toContain("/other-endpoint/resources"); + expect(url.pathname).toContain( + `/finding-groups/latest/${encodeURIComponent(maliciousCheckId)}/resources`, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Blocker 1: Resources list must show FAIL first (sort=-status) +// --------------------------------------------------------------------------- + +describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should include sort=-status in the API call so FAIL resources appear first", async () => { + // Given + const checkId = "s3_bucket_public_access"; + + // When + await getFindingGroupResources({ checkId }); + + // Then — the URL must contain sort=-status + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("sort")).toBe("-status"); + }); + + it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => { + // Given + const checkId = "s3_bucket_public_access"; + + // When + await getFindingGroupResources({ checkId }); + + // Then — the URL must contain filter[status]=FAIL + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + }); +}); + +describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should include sort=-status in the API call so FAIL resources appear first", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + + // When + await getLatestFindingGroupResources({ checkId }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("sort")).toBe("-status"); + }); + + it("should include filter[status]=FAIL in the API call so only impacted resources are shown", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + + // When + await getLatestFindingGroupResources({ checkId }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + }); +}); + +// --------------------------------------------------------------------------- +// Triangulation: sort + filter coexist with pagination and caller filters +// --------------------------------------------------------------------------- + +describe("getFindingGroupResources — triangulation: params coexist", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => { + // Given + const checkId = "s3_bucket_versioning"; + + // When + await getFindingGroupResources({ checkId, page: 2, pageSize: 50 }); + + // Then — all four params present together + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("page[number]")).toBe("2"); + expect(url.searchParams.get("page[size]")).toBe("50"); + expect(url.searchParams.get("sort")).toBe("-status"); + expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + }); +}); + +describe("getLatestFindingGroupResources — triangulation: params coexist", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should send sort=-status AND filter[status]=FAIL alongside pagination params", async () => { + // Given + const checkId = "iam_root_mfa_enabled"; + + // When + await getLatestFindingGroupResources({ checkId, page: 3, pageSize: 20 }); + + // Then + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.get("page[number]")).toBe("3"); + expect(url.searchParams.get("page[size]")).toBe("20"); + expect(url.searchParams.get("sort")).toBe("-status"); + expect(url.searchParams.get("filter[status]")).toBe("FAIL"); + }); +}); + +// --------------------------------------------------------------------------- +// Blocker: Duplicate filter[status] — caller-supplied status must be stripped +// --------------------------------------------------------------------------- + +describe("getFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => { + // Given — caller explicitly passes PASS, which must be ignored + const checkId = "s3_bucket_public_access"; + const filters = { "filter[status]": "PASS" }; + + // When + await getFindingGroupResources({ checkId, filters }); + + // Then — the final URL must have exactly one filter[status]=FAIL, not PASS + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + const allStatusValues = url.searchParams.getAll("filter[status]"); + expect(allStatusValues).toHaveLength(1); + expect(allStatusValues[0]).toBe("FAIL"); + }); + + it("should not have duplicate filter[status] params when caller passes filter[status]", async () => { + // Given + const checkId = "s3_bucket_public_access"; + const filters = { "filter[status]": "PASS" }; + + // When + await getFindingGroupResources({ checkId, filters }); + + // Then — no duplicates + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.getAll("filter[status]")).toHaveLength(1); + }); +}); + +describe("getLatestFindingGroupResources — Blocker: caller filter[status] is always overridden to FAIL", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + handleApiResponseMock.mockResolvedValue({ data: [] }); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + }); + + it("should use filter[status]=FAIL even when caller passes filter[status]=PASS", async () => { + // Given — caller explicitly passes PASS, which must be ignored + const checkId = "iam_user_mfa_enabled"; + const filters = { "filter[status]": "PASS" }; + + // When + await getLatestFindingGroupResources({ checkId, filters }); + + // Then — the final URL must have exactly one filter[status]=FAIL, not PASS + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + const allStatusValues = url.searchParams.getAll("filter[status]"); + expect(allStatusValues).toHaveLength(1); + expect(allStatusValues[0]).toBe("FAIL"); + }); + + it("should not have duplicate filter[status] params when caller passes filter[status]", async () => { + // Given + const checkId = "iam_user_mfa_enabled"; + const filters = { "filter[status]": "PASS" }; + + // When + await getLatestFindingGroupResources({ checkId, filters }); + + // Then — no duplicates + const calledUrl = fetchMock.mock.calls[0][0] as string; + const url = new URL(calledUrl); + expect(url.searchParams.getAll("filter[status]")).toHaveLength(1); + }); +}); diff --git a/ui/actions/finding-groups/finding-groups.ts b/ui/actions/finding-groups/finding-groups.ts new file mode 100644 index 0000000000..b31d7a5bfc --- /dev/null +++ b/ui/actions/finding-groups/finding-groups.ts @@ -0,0 +1,164 @@ +"use server"; + +import { redirect } from "next/navigation"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { appendSanitizedProviderFilters } from "@/lib/provider-filters"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +/** + * Maps filter[search] to filter[check_title__icontains] for finding-groups. + * The finding-groups endpoint supports check_title__icontains for substring + * matching on the human-readable check title displayed in the table. + */ +function mapSearchFilter( + filters: Record, +): Record { + const mapped = { ...filters }; + const searchValue = mapped["filter[search]"]; + if (searchValue) { + mapped["filter[check_title__icontains]"] = searchValue; + delete mapped["filter[search]"]; + } + return mapped; +} + +export const getFindingGroups = async ({ + page = 1, + pageSize = 10, + sort = "", + filters = {}, +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + if (isNaN(Number(page)) || page < 1) redirect("/findings"); + + const url = new URL(`${apiBaseUrl}/finding-groups`); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + if (sort) url.searchParams.append("sort", sort); + + appendSanitizedProviderFilters(url, mapSearchFilter(filters)); + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching finding groups:", error); + return undefined; + } +}; + +export const getLatestFindingGroups = async ({ + page = 1, + pageSize = 10, + sort = "", + filters = {}, +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + if (isNaN(Number(page)) || page < 1) redirect("/findings"); + + const url = new URL(`${apiBaseUrl}/finding-groups/latest`); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + if (sort) url.searchParams.append("sort", sort); + + appendSanitizedProviderFilters(url, mapSearchFilter(filters)); + + try { + const response = await fetch(url.toString(), { headers }); + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching latest finding groups:", error); + return undefined; + } +}; + +export const getFindingGroupResources = async ({ + checkId, + page = 1, + pageSize = 20, + filters = {}, +}: { + checkId: string; + page?: number; + pageSize?: number; + filters?: Record; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/finding-groups/${encodeURIComponent(checkId)}/resources`, + ); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + // sort=-status is kept for future-proofing: if the filter[status]=FAIL + // constraint is ever relaxed to allow multiple statuses, the sort ensures + // FAIL resources still appear first in the result set. + url.searchParams.append("sort", "-status"); + + appendSanitizedProviderFilters(url, filters); + + // Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL + // always wins, even if the caller passed a different filter[status] value. + // Using .set() instead of .append() prevents duplicate filter[status] params. + url.searchParams.set("filter[status]", "FAIL"); + + try { + const response = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching finding group resources:", error); + return undefined; + } +}; + +export const getLatestFindingGroupResources = async ({ + checkId, + page = 1, + pageSize = 20, + filters = {}, +}: { + checkId: string; + page?: number; + pageSize?: number; + filters?: Record; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/finding-groups/latest/${encodeURIComponent(checkId)}/resources`, + ); + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + // sort=-status is kept for future-proofing: if the filter[status]=FAIL + // constraint is ever relaxed to allow multiple statuses, the sort ensures + // FAIL resources still appear first in the result set. + url.searchParams.append("sort", "-status"); + + appendSanitizedProviderFilters(url, filters); + + // Use .set() AFTER appendSanitizedProviderFilters so our hardcoded FAIL + // always wins, even if the caller passed a different filter[status] value. + // Using .set() instead of .append() prevents duplicate filter[status] params. + url.searchParams.set("filter[status]", "FAIL"); + + try { + const response = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching latest finding group resources:", error); + return undefined; + } +}; diff --git a/ui/actions/finding-groups/index.ts b/ui/actions/finding-groups/index.ts new file mode 100644 index 0000000000..15fbbb78a9 --- /dev/null +++ b/ui/actions/finding-groups/index.ts @@ -0,0 +1,2 @@ +export * from "./finding-groups"; +export * from "./finding-groups.adapter"; diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts new file mode 100644 index 0000000000..b8781f0036 --- /dev/null +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Hoist mocks BEFORE imports that transitively pull next-auth +// --------------------------------------------------------------------------- + +const { createDictMock } = vi.hoisted(() => ({ + createDictMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + createDict: createDictMock, + apiBaseUrl: "https://api.example.com", + getAuthHeaders: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Import after mocks +// --------------------------------------------------------------------------- + +import { adaptFindingsByResourceResponse } from "./findings-by-resource.adapter"; + +// --------------------------------------------------------------------------- +// Fix 1: adaptFindingsByResourceResponse — unknown + type guard +// --------------------------------------------------------------------------- + +describe("adaptFindingsByResourceResponse — malformed input", () => { + beforeEach(() => { + vi.clearAllMocks(); + // createDict returns empty dict by default for most tests + createDictMock.mockReturnValue({}); + }); + + it("should return [] when apiResponse is null", () => { + // Given/When + const result = adaptFindingsByResourceResponse(null); + + // Then + 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" }); + + // Then + expect(result).toEqual([]); + }); + + it("should return [] when data is an empty array", () => { + // Given/When + const result = adaptFindingsByResourceResponse({ data: [], included: [] }); + + // Then + 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 = { + data: [ + { + id: "finding-1", + attributes: { + uid: "uid-1", + check_id: "s3_check", + status: "FAIL", + severity: "critical", + check_metadata: { + checktitle: "S3 Check", + }, + }, + relationships: { + resources: { data: [] }, + scan: { data: null }, + }, + }, + ], + included: [], + }; + + // When + const result = adaptFindingsByResourceResponse(input); + + // Then + expect(result).toHaveLength(1); + expect(result[0].id).toBe("finding-1"); + expect(result[0].checkId).toBe("s3_check"); + }); +}); diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts new file mode 100644 index 0000000000..75e8f5a81b --- /dev/null +++ b/ui/actions/findings/findings-by-resource.adapter.ts @@ -0,0 +1,309 @@ +import { createDict } from "@/lib"; +import type { ProviderType, Severity } from "@/types"; + +export interface RemediationRecommendation { + text: string; + url: string; +} + +export interface RemediationCode { + cli: string; + other: string; + nativeiac: string; + terraform: string; +} + +export interface Remediation { + recommendation: RemediationRecommendation; + code: RemediationCode; +} + +export interface ScanInfo { + id: string; + name: string; + trigger: string; + state: string; + uniqueResourceCount: number; + progress: number; + duration: number; + startedAt: string | null; + completedAt: string | null; + insertedAt: string | null; + scheduledAt: string | null; +} + +/** + * Flattened finding for the resource detail drawer. + * Merges data from the finding attributes, its check_metadata, + * the included resource, and the included scan/provider. + */ +export interface ResourceDrawerFinding { + id: string; + uid: string; + checkId: string; + checkTitle: string; + status: string; + severity: Severity; + delta: string | null; + isMuted: boolean; + mutedReason: string | null; + firstSeenAt: string | null; + updatedAt: string | null; + // Resource + resourceId: string; + resourceUid: string; + resourceName: string; + resourceService: string; + resourceRegion: string; + resourceType: string; + resourceGroup: string; + // Provider + providerType: ProviderType; + providerAlias: string; + providerUid: string; + // Check metadata (flattened) + risk: string; + description: string; + statusExtended: string; + complianceFrameworks: string[]; + categories: string[]; + remediation: Remediation; + additionalUrls: string[]; + // Scan + scan: ScanInfo | null; +} + +/** + * Extracts unique compliance framework names from available data. + * + * Supports three shapes: + * 1a. check_metadata.compliance — array of { Framework, Version, ... } objects + * e.g. [{ Framework: "CIS-AWS", Version: "1.4" }, { Framework: "PCI-DSS" }] + * 1b. check_metadata.compliance — dict with framework keys and control arrays + * e.g. {"CIS-1.4": ["1.6"], "GDPR": ["article_25"], "HIPAA": ["164_312_d"]} + * 2. finding.compliance — dict with versioned keys (when API exposes it) + * e.g. {"CIS-AWS-1.4": ["2.1"], "PCI-DSS-3.2": ["6.2"]} + */ +function extractComplianceFrameworks( + metaCompliance: unknown, + findingCompliance: Record | null | undefined, +): string[] { + const frameworks = new Set(); + + // Source 1a: check_metadata.compliance — array of objects with Framework field + if (Array.isArray(metaCompliance)) { + for (const entry of metaCompliance) { + if (entry?.Framework || entry?.framework) { + frameworks.add(entry.Framework || entry.framework); + } + } + } + // Source 1b: check_metadata.compliance — dict keyed by framework name + else if (metaCompliance && typeof metaCompliance === "object") { + for (const key of Object.keys(metaCompliance as Record)) { + const base = key.replace(/-\d+(\.\d+)*$/, ""); + frameworks.add(base); + } + } + + // Source 2: finding.compliance — dict keys like "CIS-AWS-1.4" + if (findingCompliance && typeof findingCompliance === "object") { + for (const key of Object.keys(findingCompliance)) { + const base = key.replace(/-\d+(\.\d+)*$/, ""); + frameworks.add(base); + } + } + + return Array.from(frameworks).sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }), + ); +} + +/** + * Internal shape of a finding item returned by the + * `/findings/latest?include=resources,scan.provider` endpoint. + */ +interface FindingApiAttributes { + uid: string; + check_id: string; + status: string; + severity: string; + delta?: string | null; + muted?: boolean; + muted_reason?: string | null; + first_seen_at?: string | null; + updated_at?: string | null; + status_extended?: string; + compliance?: Record; + check_metadata?: Record; +} + +interface FindingApiItem { + id: string; + attributes: FindingApiAttributes; + relationships?: { + resources?: { data?: Array<{ id: string }> }; + scan?: { data?: { id: string } | null }; + }; +} + +/** Shape of an included JSON:API resource/scan/provider entry returned by createDict. */ +interface IncludedItem { + id?: string; + attributes?: Record; + relationships?: Record; +} + +/** Lookup dict returned by createDict(). */ +type IncludedDict = Record; + +/** + * Transforms the `/findings/latest?include=resources,scan.provider` response + * into a flat ResourceDrawerFinding array. + * + * Uses createDict to build lookup maps from the JSON:API `included` array, + * then resolves each finding's resource and provider relationships. + */ +interface JsonApiResponse { + data: FindingApiItem[]; + included?: Record[]; +} + +function isJsonApiResponse(value: unknown): value is JsonApiResponse { + return ( + value !== null && + typeof value === "object" && + "data" in value && + Array.isArray((value as { data: unknown }).data) + ); +} + +export function adaptFindingsByResourceResponse( + apiResponse: unknown, +): ResourceDrawerFinding[] { + if (!isJsonApiResponse(apiResponse)) { + return []; + } + + const resourcesDict = createDict("resources", apiResponse) as IncludedDict; + const scansDict = createDict("scans", apiResponse) as IncludedDict; + const providersDict = createDict("providers", apiResponse) as IncludedDict; + + return apiResponse.data.map((item) => { + const attrs = item.attributes; + const meta = (attrs.check_metadata || {}) as Record; + const remediationRaw = meta.remediation as + | Record + | undefined; + const remediation = remediationRaw || { + recommendation: { text: "", url: "" }, + code: { cli: "", other: "", nativeiac: "", terraform: "" }, + }; + + // Resolve resource from included + const resourceRel = item.relationships?.resources?.data?.[0]; + const resource: IncludedItem | null = resourceRel + ? (resourcesDict[resourceRel.id] ?? null) + : null; + const resourceAttrs = (resource?.attributes || {}) as Record< + string, + unknown + >; + + // Resolve provider via scan → provider (include path: scan.provider) + const scanRel = item.relationships?.scan?.data; + const scan: IncludedItem | null = scanRel + ? (scansDict[scanRel.id] ?? null) + : null; + const scanRels = scan?.relationships as Record | undefined; + const providerRelId = + (( + (scanRels?.provider as Record | undefined)?.data as + | Record + | undefined + )?.id as string | null) ?? null; + const provider: IncludedItem | null = providerRelId + ? (providersDict[providerRelId] ?? null) + : null; + const providerAttrs = (provider?.attributes || {}) as Record< + string, + unknown + >; + + const remRec = remediation.recommendation as + | Record + | undefined; + const remCode = remediation.code as Record | undefined; + + return { + id: item.id, + uid: attrs.uid, + checkId: attrs.check_id, + checkTitle: (meta.checktitle as string | undefined) || attrs.check_id, + status: attrs.status, + severity: (attrs.severity || "informational") as Severity, + delta: attrs.delta || null, + isMuted: Boolean(attrs.muted), + mutedReason: attrs.muted_reason || null, + firstSeenAt: attrs.first_seen_at || null, + updatedAt: attrs.updated_at || null, + // Resource + resourceId: resourceRel?.id || "", + resourceUid: (resourceAttrs.uid as string | undefined) || "-", + resourceName: (resourceAttrs.name as string | undefined) || "-", + resourceService: (resourceAttrs.service as string | undefined) || "-", + resourceRegion: (resourceAttrs.region as string | undefined) || "-", + resourceType: (resourceAttrs.type as string | undefined) || "-", + resourceGroup: (meta.resourcegroup as string | undefined) || "-", + // Provider + providerType: ((providerAttrs.provider as string | undefined) || + "aws") as ProviderType, + providerAlias: (providerAttrs.alias as string | undefined) || "", + providerUid: (providerAttrs.uid as string | undefined) || "", + // Check metadata + risk: (meta.risk as string | undefined) || "", + description: (meta.description as string | undefined) || "", + statusExtended: attrs.status_extended || "", + complianceFrameworks: extractComplianceFrameworks( + (meta.compliance ?? meta.Compliance) as unknown, + attrs.compliance, + ), + categories: (meta.categories as string[] | undefined) || [], + remediation: { + recommendation: { + text: (remRec?.text as string | undefined) || "", + url: (remRec?.url as string | undefined) || "", + }, + code: { + cli: (remCode?.cli as string | undefined) || "", + other: (remCode?.other as string | undefined) || "", + nativeiac: (remCode?.nativeiac as string | undefined) || "", + terraform: (remCode?.terraform as string | undefined) || "", + }, + }, + additionalUrls: (meta.additionalurls as string[] | undefined) || [], + // Scan + scan: scan?.attributes + ? { + id: (scan.id as string | undefined) || "", + name: (scan.attributes.name as string | undefined) || "", + trigger: (scan.attributes.trigger as string | undefined) || "", + state: (scan.attributes.state as string | undefined) || "", + uniqueResourceCount: + (scan.attributes.unique_resource_count as number | undefined) || + 0, + progress: (scan.attributes.progress as number | undefined) || 0, + duration: (scan.attributes.duration as number | undefined) || 0, + startedAt: + (scan.attributes.started_at as string | undefined) || null, + completedAt: + (scan.attributes.completed_at as string | undefined) || null, + insertedAt: + (scan.attributes.inserted_at as string | undefined) || null, + scheduledAt: + (scan.attributes.scheduled_at as string | undefined) || null, + } + : null, + }; + }); +} diff --git a/ui/actions/findings/findings-by-resource.test.ts b/ui/actions/findings/findings-by-resource.test.ts new file mode 100644 index 0000000000..c637e5f707 --- /dev/null +++ b/ui/actions/findings/findings-by-resource.test.ts @@ -0,0 +1,422 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiResponseMock, + appendSanitizedProviderTypeFiltersMock, + getFindingGroupResourcesMock, + getLatestFindingGroupResourcesMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + appendSanitizedProviderTypeFiltersMock: vi.fn( + (url: URL, filters: Record) => { + Object.entries(filters).forEach(([key, value]) => { + if (key !== "filter[search]") { + url.searchParams.append(key, value); + } + }); + }, + ), + getFindingGroupResourcesMock: vi.fn(), + getLatestFindingGroupResourcesMock: vi.fn(), +})); + +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, +})); + +vi.mock("@/actions/finding-groups", () => ({ + getFindingGroupResources: getFindingGroupResourcesMock, + getLatestFindingGroupResources: getLatestFindingGroupResourcesMock, +})); + +import { + resolveFindingIds, + resolveFindingIdsByCheckIds, + resolveFindingIdsByVisibleGroupResources, +} from "./findings-by-resource"; + +describe("resolveFindingIdsByCheckIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("should resolve all finding IDs across every page for the latest endpoint", async () => { + // Given + fetchMock + .mockResolvedValueOnce(new Response("", { status: 200 })) + .mockResolvedValueOnce(new Response("", { status: 200 })) + .mockResolvedValueOnce(new Response("", { status: 200 })); + + handleApiResponseMock + .mockResolvedValueOnce({ + data: [{ id: "finding-1" }, { id: "finding-2" }], + meta: { pagination: { pages: 3 } }, + }) + .mockResolvedValueOnce({ + data: [{ id: "finding-3" }], + meta: { pagination: { pages: 3 } }, + }) + .mockResolvedValueOnce({ + data: [{ id: "finding-4" }], + meta: { pagination: { pages: 3 } }, + }); + + // When + const result = await resolveFindingIdsByCheckIds({ + checkIds: ["check-1", "check-2"], + filters: { + "filter[provider_type__in]": "aws", + "filter[search]": "ignored-search", + }, + }); + + // Then + expect(result).toEqual([ + "finding-1", + "finding-2", + "finding-3", + "finding-4", + ]); + expect(fetchMock).toHaveBeenCalledTimes(3); + + const firstCallUrl = new URL(fetchMock.mock.calls[0][0]); + expect(firstCallUrl.pathname).toBe("/api/v1/findings/latest"); + expect(firstCallUrl.searchParams.get("filter[check_id__in]")).toBe( + "check-1,check-2", + ); + expect(firstCallUrl.searchParams.get("filter[muted]")).toBe("false"); + expect(firstCallUrl.searchParams.get("page[size]")).toBe("500"); + expect(firstCallUrl.searchParams.get("page[number]")).toBe("1"); + expect(firstCallUrl.searchParams.get("fields[findings]")).toBe("uid"); + expect(firstCallUrl.searchParams.get("filter[provider_type__in]")).toBe( + "aws", + ); + expect(firstCallUrl.searchParams.get("filter[search]")).toBeNull(); + + const laterPages = fetchMock.mock.calls + .slice(1) + .map(([url]) => new URL(url).searchParams.get("page[number]")); + expect(laterPages.sort()).toEqual(["2", "3"]); + }); + + it("should use the dated findings endpoint when date or scan filters are active", async () => { + // Given + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }], + meta: { pagination: { pages: 1 } }, + }); + + // When + await resolveFindingIdsByCheckIds({ + checkIds: ["check-1"], + hasDateOrScanFilter: true, + filters: { + "filter[scan__in]": "scan-1", + "filter[inserted_at__gte]": "2026-03-01", + }, + }); + + // Then + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/findings"); + expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1"); + expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe( + "2026-03-01", + ); + }); +}); + +describe("resolveFindingIds", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("should use the dated findings endpoint when date or scan filters are active", async () => { + // Given + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }, { id: "finding-2" }], + }); + + // When + const result = await resolveFindingIds({ + checkId: "check-1", + resourceUids: ["resource-1", "resource-2"], + hasDateOrScanFilter: true, + filters: { + "filter[scan__in]": "scan-1", + "filter[inserted_at__gte]": "2026-03-01", + }, + }); + + // Then + expect(result).toEqual(["finding-1", "finding-2"]); + + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/findings"); + expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1"); + expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe( + "resource-1,resource-2", + ); + expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1"); + expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe( + "2026-03-01", + ); + }); +}); + +describe("resolveFindingIdsByVisibleGroupResources", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("should resolve finding IDs from the group's visible resource UIDs instead of muting the whole check", async () => { + // Given + getLatestFindingGroupResourcesMock + .mockResolvedValueOnce({ + data: [ + { + id: "resource-row-1", + attributes: { + resource: { uid: "resource-1" }, + }, + }, + { + id: "resource-row-2", + attributes: { + resource: { uid: "resource-2" }, + }, + }, + ], + meta: { pagination: { pages: 2 } }, + }) + .mockResolvedValueOnce({ + data: [ + { + id: "resource-row-3", + attributes: { + resource: { uid: "resource-3" }, + }, + }, + ], + meta: { pagination: { pages: 2 } }, + }); + + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }], + }); + + // When + const result = await resolveFindingIdsByVisibleGroupResources({ + checkId: "check-1", + filters: { + "filter[provider_type__in]": "aws", + }, + resourceSearch: "visible subset", + }); + + // Then + expect(result).toEqual(["finding-1", "finding-2", "finding-3"]); + expect(getLatestFindingGroupResourcesMock).toHaveBeenCalledTimes(2); + expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(1, { + checkId: "check-1", + page: 1, + pageSize: 500, + filters: { + "filter[provider_type__in]": "aws", + "filter[name__icontains]": "visible subset", + }, + }); + expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(2, { + checkId: "check-1", + page: 2, + pageSize: 500, + filters: { + "filter[provider_type__in]": "aws", + "filter[name__icontains]": "visible subset", + }, + }); + + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/findings/latest"); + expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1"); + expect(calledUrl.searchParams.get("filter[check_id__in]")).toBeNull(); + expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe( + "resource-1,resource-2,resource-3", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Blocker 3: Muting a group mutes ALL historical findings, not just FAIL ones +// +// The fix: resolveFindingIds must include filter[status]=FAIL so only active +// (failing) findings are resolved for mute, not historical/passing ones. +// --------------------------------------------------------------------------- + +describe("resolveFindingIds — Blocker 3: only resolve FAIL findings for mute", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("should include filter[status]=FAIL in the findings resolution URL for mute", async () => { + // Given + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }, { id: "finding-2" }], + }); + + // When + await resolveFindingIds({ + checkId: "check-1", + resourceUids: ["resource-1", "resource-2"], + }); + + // Then — the URL must filter to only FAIL status findings + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); + }); + + it("should include filter[status]=FAIL even when date or scan filters are active", async () => { + // Given + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }], + }); + + // When + await resolveFindingIds({ + checkId: "check-1", + resourceUids: ["resource-1"], + hasDateOrScanFilter: true, + filters: { + "filter[inserted_at__gte]": "2026-01-01", + }, + }); + + // Then + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/findings"); + expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL"); + }); + + it("should override caller filter[status] with FAIL — no duplicate params", async () => { + // Given — caller passes filter[status]=PASS via filters dict + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }], + }); + + // When + await resolveFindingIds({ + checkId: "check-1", + resourceUids: ["resource-1"], + filters: { + "filter[status]": "PASS", + }, + }); + + // Then — hardcoded FAIL must win, exactly 1 value + const calledUrl = new URL(fetchMock.mock.calls[0][0] as string); + const statusValues = calledUrl.searchParams.getAll("filter[status]"); + expect(statusValues).toHaveLength(1); + expect(statusValues[0]).toBe("FAIL"); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 4: Unbounded page[size] cap +// +// The bug: createResourceFindingResolutionUrl sets page[size]=resourceUids.length +// with no upper bound guard. The production fix adds Math.min(resourceUids.length, MAX_PAGE_SIZE) +// with MAX_PAGE_SIZE=500 as an explicit defensive cap. +// --------------------------------------------------------------------------- + +describe("resolveFindingIds — Fix 4: page[size] explicit cap at MAX_PAGE_SIZE=500", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("should use resourceUids.length as page[size] for a small batch (under 500)", async () => { + // Given — 3 resources, well under the cap + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ + data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }], + }); + + // When + await resolveFindingIds({ + checkId: "check-1", + resourceUids: ["resource-1", "resource-2", "resource-3"], + }); + + // Then — page[size] should equal the number of resourceUids (3) + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.searchParams.get("page[size]")).toBe("3"); + }); + + it("should cap page[size] at 500 when the chunk has exactly 500 UIDs (boundary value)", async () => { + // Given — exactly 500 unique UIDs (at the cap boundary) + const resourceUids = Array.from({ length: 500 }, (_, i) => `resource-${i}`); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await resolveFindingIds({ + checkId: "check-1", + resourceUids, + }); + + // Then — page[size] must be exactly 500 (not capped lower) + const firstUrl = new URL(fetchMock.mock.calls[0][0] as string); + expect(firstUrl.searchParams.get("page[size]")).toBe("500"); + }); + + it("should cap page[size] at 500 even when a chunk would exceed 500 — Math.min guard in URL builder", async () => { + // Given — 501 UIDs. The chunker splits into [500, 1]. + // The FIRST chunk has 500 UIDs → page[size] should be 500 (Math.min(500, 500)). + // The SECOND chunk has 1 UID → page[size] should be 1 (Math.min(1, 500)). + // This proves the Math.min cap fires correctly on every chunk. + const resourceUids = Array.from({ length: 501 }, (_, i) => `resource-${i}`); + fetchMock.mockResolvedValue(new Response("", { status: 200 })); + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await resolveFindingIds({ + checkId: "check-1", + resourceUids, + }); + + // Then — two fetch calls: one for 500 UIDs, one for 1 UID + expect(fetchMock).toHaveBeenCalledTimes(2); + const firstUrl = new URL(fetchMock.mock.calls[0][0] as string); + const secondUrl = new URL(fetchMock.mock.calls[1][0] as string); + expect(firstUrl.searchParams.get("page[size]")).toBe("500"); + expect(secondUrl.searchParams.get("page[size]")).toBe("1"); + }); +}); diff --git a/ui/actions/findings/findings-by-resource.ts b/ui/actions/findings/findings-by-resource.ts new file mode 100644 index 0000000000..12900ffdca --- /dev/null +++ b/ui/actions/findings/findings-by-resource.ts @@ -0,0 +1,395 @@ +"use server"; + +import { + getFindingGroupResources, + getLatestFindingGroupResources, +} from "@/actions/finding-groups"; +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { runWithConcurrencyLimit } from "@/lib/concurrency"; +import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters"; +import { handleApiResponse } from "@/lib/server-actions-helper"; + +const FINDING_IDS_RESOLUTION_PAGE_SIZE = 500; +const FINDING_IDS_RESOLUTION_CONCURRENCY = 4; +const FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE = 500; +const FINDING_FIELDS = "uid"; + +/** Explicit upper bound for page[size] in resource-finding resolution requests. */ +const MAX_RESOURCE_FINDING_PAGE_SIZE = 500; + +interface ResolveFindingIdsByCheckIdsParams { + checkIds: string[]; + filters?: Record; + hasDateOrScanFilter?: boolean; +} + +interface ResolveFindingIdsParams { + checkId: string; + resourceUids: string[]; + filters?: Record; + hasDateOrScanFilter?: boolean; +} + +interface ResolveFindingIdsByVisibleGroupResourcesParams { + checkId: string; + filters?: Record; + hasDateOrScanFilter?: boolean; + resourceSearch?: string; +} + +interface FindingIdsPageResponse { + ids: string[]; + totalPages: number; +} + +interface FindingGroupResourceUidsPageResponse { + resourceUids: string[]; + totalPages: number; +} + +function createFindingsResolutionUrl({ + checkIds, + filters = {}, + page, + hasDateOrScanFilter = false, +}: ResolveFindingIdsByCheckIdsParams & { + page: number; +}): URL { + const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest"; + const url = new URL(`${apiBaseUrl}/${endpoint}`); + + url.searchParams.append("filter[check_id__in]", checkIds.join(",")); + url.searchParams.append("filter[muted]", "false"); + url.searchParams.append("fields[findings]", FINDING_FIELDS); + url.searchParams.append("page[number]", page.toString()); + url.searchParams.append( + "page[size]", + FINDING_IDS_RESOLUTION_PAGE_SIZE.toString(), + ); + + appendSanitizedProviderTypeFilters(url, filters); + + return url; +} + +async function fetchFindingIdsPage({ + headers, + page, + ...params +}: ResolveFindingIdsByCheckIdsParams & { + headers: HeadersInit; + page: number; +}): Promise { + const response = await fetch( + createFindingsResolutionUrl({ ...params, page }).toString(), + { + headers, + }, + ); + const data = await handleApiResponse(response); + + if (!data?.data || !Array.isArray(data.data)) { + return { ids: [], totalPages: 1 }; + } + + return { + ids: data.data + .map((item: { id?: string }) => item.id) + .filter((id: string | undefined): id is string => Boolean(id)), + totalPages: data?.meta?.pagination?.pages ?? 1, + }; +} + +function chunkValues(values: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += chunkSize) { + chunks.push(values.slice(index, index + chunkSize)); + } + return chunks; +} + +function createResourceFindingResolutionUrl({ + checkId, + resourceUids, + filters = {}, + hasDateOrScanFilter = false, +}: ResolveFindingIdsParams): URL { + const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest"; + const url = new URL(`${apiBaseUrl}/${endpoint}`); + + url.searchParams.append("filter[check_id]", checkId); + url.searchParams.append("filter[resource_uid__in]", resourceUids.join(",")); + url.searchParams.append("filter[muted]", "false"); + url.searchParams.append( + "page[size]", + Math.min(resourceUids.length, MAX_RESOURCE_FINDING_PAGE_SIZE).toString(), + ); + + appendSanitizedProviderTypeFilters(url, filters); + + // Hardcoded FAIL filter AFTER appendSanitizedProviderTypeFilters — .set() + // guarantees this wins even if the caller passes filter[status] in filters. + url.searchParams.set("filter[status]", "FAIL"); + + return url; +} + +async function fetchFindingIdsForResourceUids({ + headers, + ...params +}: ResolveFindingIdsParams & { + headers: HeadersInit; +}): Promise { + const response = await fetch( + createResourceFindingResolutionUrl(params).toString(), + { + headers, + }, + ); + const data = await handleApiResponse(response); + + if (!data?.data || !Array.isArray(data.data)) { + return []; + } + + return data.data + .map((item: { id?: string }) => item.id) + .filter((id: string | undefined): id is string => Boolean(id)); +} + +function buildFindingGroupResourceFilters({ + filters = {}, + resourceSearch, +}: Pick< + ResolveFindingIdsByVisibleGroupResourcesParams, + "filters" | "resourceSearch" +>): Record { + const nextFilters = { ...filters }; + if (resourceSearch) { + nextFilters["filter[name__icontains]"] = resourceSearch; + } + return nextFilters; +} + +async function fetchFindingGroupResourceUidsPage({ + checkId, + filters = {}, + hasDateOrScanFilter = false, + page, + resourceSearch, +}: ResolveFindingIdsByVisibleGroupResourcesParams & { + page: number; +}): Promise { + const fetchFn = hasDateOrScanFilter + ? getFindingGroupResources + : getLatestFindingGroupResources; + + const response = await fetchFn({ + checkId, + page, + pageSize: FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE, + filters: buildFindingGroupResourceFilters({ filters, resourceSearch }), + }); + + const data = response?.data; + + if (!data || !Array.isArray(data)) { + return { resourceUids: [], totalPages: 1 }; + } + + return { + resourceUids: data + .map( + (item: { attributes?: { resource?: { uid?: string } } }) => + item.attributes?.resource?.uid, + ) + .filter((uid: string | undefined): uid is string => Boolean(uid)), + totalPages: response?.meta?.pagination?.pages ?? 1, + }; +} + +/** + * Resolves resource UIDs + check ID into actual finding UUIDs. + * Uses /findings/latest (or /findings when date/scan filters are active) + * with check_id and resource_uid__in filters to batch-resolve actual finding IDs. + */ +export const resolveFindingIds = async ({ + checkId, + resourceUids, + filters = {}, + hasDateOrScanFilter = false, +}: ResolveFindingIdsParams): Promise => { + if (resourceUids.length === 0) { + return []; + } + + const headers = await getAuthHeaders({ contentType: false }); + const resourceUidChunks = chunkValues( + Array.from(new Set(resourceUids)), + FINDING_IDS_RESOLUTION_PAGE_SIZE, + ); + + try { + const results = await runWithConcurrencyLimit( + resourceUidChunks, + FINDING_IDS_RESOLUTION_CONCURRENCY, + (resourceUidChunk) => + fetchFindingIdsForResourceUids({ + checkId, + resourceUids: resourceUidChunk, + filters, + hasDateOrScanFilter, + headers, + }), + ); + + return Array.from(new Set(results.flat())); + } catch (error) { + console.error("Error resolving finding IDs:", error); + return []; + } +}; + +/** + * Resolves check IDs into actual finding UUIDs. + * Used at the group level where each row represents a check_id. + */ +export const resolveFindingIdsByCheckIds = async ({ + checkIds, + filters = {}, + hasDateOrScanFilter = false, +}: ResolveFindingIdsByCheckIdsParams): Promise => { + if (checkIds.length === 0) { + return []; + } + + const headers = await getAuthHeaders({ contentType: false }); + + try { + const firstPage = await fetchFindingIdsPage({ + checkIds, + filters, + hasDateOrScanFilter, + headers, + page: 1, + }); + + const remainingPages = Array.from( + { length: Math.max(0, firstPage.totalPages - 1) }, + (_, index) => index + 2, + ); + + const remainingResults = await runWithConcurrencyLimit( + remainingPages, + FINDING_IDS_RESOLUTION_CONCURRENCY, + async (page) => + fetchFindingIdsPage({ + checkIds, + filters, + hasDateOrScanFilter, + headers, + page, + }), + ); + + return Array.from( + new Set([ + ...firstPage.ids, + ...remainingResults.flatMap((result) => result.ids), + ]), + ); + } catch (error) { + console.error("Error resolving finding IDs by check IDs:", error); + return []; + } +}; + +/** + * Resolves a finding-group row to the actual findings for the resources + * currently visible in that group. + */ +export const resolveFindingIdsByVisibleGroupResources = async ({ + checkId, + filters = {}, + hasDateOrScanFilter = false, + resourceSearch, +}: ResolveFindingIdsByVisibleGroupResourcesParams): Promise => { + try { + const firstPage = await fetchFindingGroupResourceUidsPage({ + checkId, + filters, + hasDateOrScanFilter, + page: 1, + resourceSearch, + }); + + const remainingPages = Array.from( + { length: Math.max(0, firstPage.totalPages - 1) }, + (_, index) => index + 2, + ); + + const remainingResults = await runWithConcurrencyLimit( + remainingPages, + FINDING_IDS_RESOLUTION_CONCURRENCY, + (page) => + fetchFindingGroupResourceUidsPage({ + checkId, + filters, + hasDateOrScanFilter, + page, + resourceSearch, + }), + ); + + const resourceUids = Array.from( + new Set([ + ...firstPage.resourceUids, + ...remainingResults.flatMap((result) => result.resourceUids), + ]), + ); + + return resolveFindingIds({ + checkId, + resourceUids, + filters, + hasDateOrScanFilter, + }); + } catch (error) { + console.error( + "Error resolving finding IDs from visible group resources:", + error, + ); + return []; + } +}; + +export const getLatestFindingsByResourceUid = async ({ + resourceUid, + page = 1, + pageSize = 50, +}: { + resourceUid: string; + page?: number; + pageSize?: number; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL( + `${apiBaseUrl}/findings/latest?include=resources,scan.provider`, + ); + + url.searchParams.append("filter[resource_uid]", resourceUid); + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + + try { + const findings = await fetch(url.toString(), { + headers, + }); + + return handleApiResponse(findings); + } catch (error) { + console.error("Error fetching findings by resource UID:", error); + return undefined; + } +}; diff --git a/ui/actions/findings/index.ts b/ui/actions/findings/index.ts index eb3a674c67..d9fd5ae3b5 100644 --- a/ui/actions/findings/index.ts +++ b/ui/actions/findings/index.ts @@ -1 +1,3 @@ export * from "./findings"; +export * from "./findings-by-resource"; +export * from "./findings-by-resource.adapter"; diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index 715e70c2b5..72bfc1176d 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -9,6 +9,42 @@ import type { JiraDispatchResponse, } from "@/types/integrations"; +export const getJiraIssueTypes = async ( + integrationId: string, + projectKey: string, +): Promise< + { success: true; issueTypes: string[] } | { success: false; error: string } +> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL( + `${apiBaseUrl}/integrations/${integrationId}/jira/issue_types`, + ); + url.searchParams.append("project_key", projectKey); + + try { + const response = await fetch(url.toString(), { method: "GET", headers }); + + if (response.ok) { + const data: { + data: { type: string; attributes: { issue_types: string[] } }; + } = await response.json(); + return { + success: true, + issueTypes: data.data?.attributes?.issue_types ?? [], + }; + } + + const errorData: unknown = await response.json().catch(() => ({})); + const errorMessage = + (errorData as { errors?: { detail?: string }[] }).errors?.[0]?.detail || + `Unable to fetch issue types: ${response.statusText}`; + return { success: false, error: errorMessage }; + } catch (error) { + const errorResult = handleApiError(error); + return { success: false, error: errorResult.error || "An error occurred" }; + } +}; + export const getJiraIntegrations = async (): Promise< | { success: true; data: IntegrationProps[] } | { success: false; error: string } @@ -47,7 +83,7 @@ export const sendFindingToJira = async ( integrationId: string, findingId: string, projectKey: string, - _issueType: string, + issueType: string, ): Promise< | { success: true; taskId: string; message: string } | { success: false; error: string } @@ -65,8 +101,7 @@ export const sendFindingToJira = async ( type: "integrations-jira-dispatches", attributes: { project_key: projectKey, - // Temporarily hardcode to "Task" regardless of the provided value - issue_type: "Task", + issue_type: issueType, }, }, }; diff --git a/ui/actions/manage-groups/manage-groups.ts b/ui/actions/manage-groups/manage-groups.ts index 8899029668..8758b66f68 100644 --- a/ui/actions/manage-groups/manage-groups.ts +++ b/ui/actions/manage-groups/manage-groups.ts @@ -22,7 +22,8 @@ export const getProviderGroups = async ({ }): Promise => { const headers = await getAuthHeaders({ contentType: false }); - if (isNaN(Number(page)) || page < 1) redirect("/manage-groups"); + if (isNaN(Number(page)) || page < 1) + redirect("/providers?tab=account-groups"); const url = new URL(`${apiBaseUrl}/provider-groups`); @@ -43,7 +44,7 @@ export const getProviderGroups = async ({ headers, }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { console.error("Error fetching provider groups:", error); return undefined; @@ -60,7 +61,7 @@ export const getProviderGroupInfoById = async (providerGroupId: string) => { headers, }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { handleApiError(error); } @@ -111,7 +112,7 @@ export const createProviderGroup = async (formData: FormData) => { body, }); - return handleApiResponse(response, "/manage-groups"); + return await handleApiResponse(response, "/providers?tab=account-groups"); } catch (error) { handleApiError(error); } @@ -156,7 +157,7 @@ export const updateProviderGroup = async ( body: JSON.stringify(payload), }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { handleApiError(error); } @@ -168,7 +169,7 @@ export const deleteProviderGroup = async (formData: FormData) => { if (!providerGroupId) { return { - errors: [{ detail: "Provider Group ID is required." }], + errors: [{ detail: "Account Group ID is required." }], }; } @@ -196,7 +197,7 @@ export const deleteProviderGroup = async (formData: FormData) => { data = await response.json(); } - revalidatePath("/manage-groups"); + revalidatePath("/providers"); return data || { success: true }; } catch (error) { console.error("Error deleting provider group:", error); diff --git a/ui/actions/mute-rules/mute-rules.ts b/ui/actions/mute-rules/mute-rules.ts index d8c5b7bbd4..cc31bd90bd 100644 --- a/ui/actions/mute-rules/mute-rules.ts +++ b/ui/actions/mute-rules/mute-rules.ts @@ -182,6 +182,9 @@ export const createMuteRule = async ( } }; +// Note: Adding findings to existing mute rules is not supported by the API. +// The MuteRuleUpdateSerializer only allows updating name, reason, and enabled fields. +// finding_ids can only be specified when creating a new mute rule. export const updateMuteRule = async ( _prevState: MuteRuleActionState, formData: FormData, @@ -374,10 +377,6 @@ export const deleteMuteRule = async ( } }; -// Note: Adding findings to existing mute rules is not supported by the API. -// The MuteRuleUpdateSerializer only allows updating name, reason, and enabled fields. -// finding_ids can only be specified when creating a new mute rule. - // Note: Unmute functionality is not currently supported by the API. // The FindingViewSet only allows GET operations, and deleting a mute rule // does not unmute the findings ("Previously muted findings remain muted"). diff --git a/ui/actions/organizations/organizations.test.ts b/ui/actions/organizations/organizations.test.ts index 52a4b82eb3..be1eb6c404 100644 --- a/ui/actions/organizations/organizations.test.ts +++ b/ui/actions/organizations/organizations.test.ts @@ -31,6 +31,10 @@ vi.mock("@/lib/server-actions-helper", () => ({ import { applyDiscovery, getDiscovery, + listOrganizations, + listOrganizationsSafe, + listOrganizationUnits, + listOrganizationUnitsSafe, triggerDiscovery, updateOrganizationSecret, } from "./organizations"; @@ -137,4 +141,66 @@ describe("organizations actions", () => { expect(revalidatePathMock).toHaveBeenCalledTimes(1); expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); }); + + it("lists organizations with the expected filters", async () => { + // Given + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await listOrganizations(); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "https://api.example.com/api/v1/organizations?filter%5Borg_type%5D=aws", + ); + }); + + it("lists organization units from the dedicated endpoint", async () => { + // Given + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await listOrganizationUnits(); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "https://api.example.com/api/v1/organizational-units", + ); + }); + + it("returns an empty organizations payload when the safe organizations request fails", async () => { + // Given + fetchMock.mockResolvedValue( + new Response("Internal Server Error", { + status: 500, + }), + ); + + // When + const result = await listOrganizationsSafe(); + + // Then + expect(result).toEqual({ data: [] }); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + expect(handleApiErrorMock).not.toHaveBeenCalled(); + }); + + it("returns an empty organization units payload when the safe request fails", async () => { + // Given + fetchMock.mockResolvedValue( + new Response("Internal Server Error", { + status: 500, + }), + ); + + // When + const result = await listOrganizationUnitsSafe(); + + // Then + expect(result).toEqual({ data: [] }); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + expect(handleApiErrorMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/actions/organizations/organizations.ts b/ui/actions/organizations/organizations.ts index 16ddd7593d..9c3caf1080 100644 --- a/ui/actions/organizations/organizations.ts +++ b/ui/actions/organizations/organizations.ts @@ -4,6 +4,10 @@ import { revalidatePath } from "next/cache"; import { apiBaseUrl, getAuthHeaders } from "@/lib"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { + OrganizationListResponse, + OrganizationUnitListResponse, +} from "@/types"; const PATH_IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/; @@ -37,6 +41,24 @@ function hasActionError(result: unknown): result is { error: unknown } { ); } +async function fetchOptionalCollection( + url: URL, +): Promise { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const response = await fetch(url.toString(), { headers }); + + if (!response.ok) { + return { data: [] } as unknown as T; + } + + return (await handleApiResponse(response)) as T; + } catch { + return { data: [] } as unknown as T; + } +} + /** * Creates an AWS Organization resource. * POST /api/v1/organizations @@ -70,6 +92,59 @@ export const createOrganization = async (formData: FormData) => { } }; +/** + * Updates an AWS Organization's name. + * PATCH /api/v1/organizations/{id} + */ +export const updateOrganizationName = async ( + organizationId: string, + name: string, +) => { + const trimmed = name.trim(); + if (!trimmed) { + return { error: "Organization name cannot be empty." }; + } + + const headers = await getAuthHeaders({ contentType: true }); + + const idValidation = validatePathIdentifier( + organizationId, + "Organization ID is required", + "Invalid organization ID", + ); + if ("error" in idValidation) { + return idValidation; + } + + const url = new URL( + `${apiBaseUrl}/organizations/${encodeURIComponent(idValidation.value)}`, + ); + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify({ + data: { + type: "organizations", + id: idValidation.value, + attributes: { + name: trimmed, + }, + }, + }), + }); + + const result = await handleApiResponse(response); + if (!hasActionError(result)) { + revalidatePath("/providers"); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + /** * Lists AWS Organizations filtered by external ID. * GET /api/v1/organizations?filter[external_id]={externalId}&filter[org_type]=aws @@ -82,12 +157,62 @@ export const listOrganizationsByExternalId = async (externalId: string) => { try { const response = await fetch(url.toString(), { headers }); - return handleApiResponse(response); + return await handleApiResponse(response); } catch (error) { return handleApiError(error); } }; +/** + * Lists AWS organizations available for the current tenant. + * GET /api/v1/organizations?filter[org_type]=aws + */ +export const listOrganizations = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/organizations`); + url.searchParams.set("filter[org_type]", "aws"); + + try { + const response = await fetch(url.toString(), { headers }); + return await handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const listOrganizationsSafe = + async (): Promise => { + const url = new URL(`${apiBaseUrl}/organizations`); + url.searchParams.set("filter[org_type]", "aws"); + url.searchParams.set("page[size]", "100"); + + return fetchOptionalCollection(url); + }; + +/** + * Lists organization units available for the current tenant. + * GET /api/v1/organizational-units + */ +export const listOrganizationUnits = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/organizational-units`); + + try { + const response = await fetch(url.toString(), { headers }); + return await handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +export const listOrganizationUnitsSafe = + async (): Promise => { + const url = new URL(`${apiBaseUrl}/organizational-units`); + url.searchParams.set("page[size]", "100"); + + return fetchOptionalCollection(url); + }; + /** * Creates an organization secret (role-based credentials). * POST /api/v1/organization-secrets @@ -201,6 +326,72 @@ export const listOrganizationSecretsByOrganizationId = async ( } }; +/** + * Deletes an AWS Organization resource. + * DELETE /api/v1/organizations/{id} + */ +export const deleteOrganization = async (organizationId: string) => { + const headers = await getAuthHeaders({ contentType: false }); + + const organizationIdValidation = validatePathIdentifier( + organizationId, + "Organization ID is required", + "Invalid organization ID", + ); + if ("error" in organizationIdValidation) { + return organizationIdValidation; + } + + const url = new URL( + `${apiBaseUrl}/organizations/${encodeURIComponent(organizationIdValidation.value)}`, + ); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + return handleApiResponse(response, "/providers"); + } catch (error) { + return handleApiError(error); + } +}; + +/** + * Deletes an organizational unit. + * DELETE /api/v1/organizational-units/{id} + */ +export const deleteOrganizationalUnit = async ( + organizationalUnitId: string, +) => { + const headers = await getAuthHeaders({ contentType: false }); + + const idValidation = validatePathIdentifier( + organizationalUnitId, + "Organizational unit ID is required", + "Invalid organizational unit ID", + ); + if ("error" in idValidation) { + return idValidation; + } + + const url = new URL( + `${apiBaseUrl}/organizational-units/${encodeURIComponent(idValidation.value)}`, + ); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + return handleApiResponse(response, "/providers"); + } catch (error) { + return handleApiError(error); + } +}; + /** * Triggers an async discovery of the AWS Organization. * POST /api/v1/organizations/{id}/discover diff --git a/ui/actions/resources/index.ts b/ui/actions/resources/index.ts index e4e24e4d9a..afaf91a381 100644 --- a/ui/actions/resources/index.ts +++ b/ui/actions/resources/index.ts @@ -3,5 +3,6 @@ export { getLatestResources, getMetadataInfo, getResourceById, + getResourceEvents, getResources, } from "./resources"; diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts new file mode 100644 index 0000000000..b3fc4b257b --- /dev/null +++ b/ui/actions/resources/resources.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +vi.mock("@/lib/provider-filters", () => ({ + appendSanitizedProviderTypeFilters: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +import { getResourceEvents } from "./resources"; + +describe("getResourceEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("calls the correct API endpoint with default parameters", async () => { + // Given + const mockResponse = new Response("", { status: 200 }); + fetchMock.mockResolvedValue(mockResponse); + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await getResourceEvents("resource-123"); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/resources/resource-123/events"); + expect(calledUrl.searchParams.get("include_read_events")).toBe("false"); + expect(calledUrl.searchParams.get("lookback_days")).toBe("90"); + expect(calledUrl.searchParams.get("page[size]")).toBe("50"); + }); + + it("passes custom parameters to the API", async () => { + // Given + const mockResponse = new Response("", { status: 200 }); + fetchMock.mockResolvedValue(mockResponse); + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await getResourceEvents("resource-456", { + includeReadEvents: true, + lookbackDays: 30, + pageSize: 25, + }); + + // Then + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.searchParams.get("include_read_events")).toBe("true"); + expect(calledUrl.searchParams.get("lookback_days")).toBe("30"); + 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({ + errors: [ + { + detail: + "Provider credentials are invalid or expired. Please reconnect the provider.", + }, + ], + }); + const mockResponse = new Response(errorBody, { + status: 502, + statusText: "Bad Gateway", + }); + fetchMock.mockResolvedValue(mockResponse); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ + error: + "Provider credentials are invalid or expired. Please reconnect the provider.", + status: 502, + }); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); + + it("returns error with statusText when response body is not JSON", async () => { + // Given + const mockResponse = new Response("Service Unavailable", { + status: 503, + statusText: "Service Unavailable", + }); + fetchMock.mockResolvedValue(mockResponse); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ + error: "Service Unavailable", + status: 503, + }); + }); + + it("returns generic error when fetch throws", async () => { + // Given + fetchMock.mockRejectedValue(new Error("Network failure")); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ error: "An unexpected error occurred." }); + }); + + it.each([ + "../../../etc/passwd", + "resource/../../secret", + "id with spaces", + "id;rm -rf /", + "", + "resource%00id", + "", + ])("rejects malicious or invalid resourceId: %s", async (maliciousId) => { + // When + const result = await getResourceEvents(maliciousId); + + // Then + expect(result).toEqual({ error: "Invalid resource ID format." }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index d65505704f..36f9761959 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -160,6 +160,64 @@ export const getLatestMetadataInfo = async ({ } }; +const SAFE_RESOURCE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; + +export const getResourceEvents = async ( + resourceId: string, + { + includeReadEvents = false, + lookbackDays = 90, + pageSize = 50, + }: { + includeReadEvents?: boolean; + lookbackDays?: number; + pageSize?: number; + } = {}, +) => { + if (!SAFE_RESOURCE_ID_PATTERN.test(resourceId)) { + return { error: "Invalid resource ID format." }; + } + + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/resources/${resourceId}/events`); + url.searchParams.append("include_read_events", String(includeReadEvents)); + url.searchParams.append("lookback_days", String(lookbackDays)); + url.searchParams.append("page[size]", String(pageSize)); + + try { + const response = await fetch(url.toString(), { headers }); + + if (!response.ok) { + const rawText = await response.text().catch(() => ""); + const defaultError = "An error occurred while fetching events."; + try { + const errorData = rawText ? JSON.parse(rawText) : null; + return { + error: + errorData?.errors?.[0]?.detail || + errorData?.error || + errorData?.message || + rawText || + response.statusText || + defaultError, + status: response.status, + }; + } catch { + return { + error: rawText || response.statusText || defaultError, + status: response.status, + }; + } + } + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching resource events:", error); + return { error: "An unexpected error occurred." }; + } +}; + export const getResourceById = async ( id: string, { diff --git a/ui/actions/users/tenants.ts b/ui/actions/users/tenants.ts index f730a02b7f..b43b467d93 100644 --- a/ui/actions/users/tenants.ts +++ b/ui/actions/users/tenants.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { z } from "zod"; import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; @@ -37,7 +38,15 @@ const editTenantFormSchema = z path: ["name"], }); -export async function updateTenantName(_prevState: any, formData: FormData) { +export type UpdateTenantNameState = + | { errors: { name?: string } } + | { success: string } + | { error: string }; + +export async function updateTenantName( + _prevState: UpdateTenantNameState | null, + formData: FormData, +): Promise { const headers = await getAuthHeaders({ contentType: true }); const formDataObject = Object.fromEntries(formData); const validatedData = editTenantFormSchema.safeParse(formDataObject); @@ -82,3 +91,311 @@ export async function updateTenantName(_prevState: any, formData: FormData) { return handleApiError(error); } } + +const switchTenantSchema = z.object({ + tenantId: z.uuid(), +}); + +interface SwitchTenantSuccess { + success: true; + accessToken: string; + refreshToken: string; +} + +interface SwitchTenantError { + error: string; +} + +export type SwitchTenantState = SwitchTenantSuccess | SwitchTenantError; + +export async function switchTenant( + _prevState: SwitchTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = switchTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + return { error: "Invalid tenant ID" }; + } + + const { tenantId } = validatedData.data; + const headers = await getAuthHeaders({ contentType: true }); + + const payload = { + data: { + type: "tokens-switch-tenant", + attributes: { + tenant_id: tenantId, + }, + }, + }; + + try { + const url = new URL(`${apiBaseUrl}/tokens/switch`); + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to switch tenant: ${response.statusText}`; + throw new Error(errorDetail); + } + + const data = await response.json(); + const accessToken = data?.data?.attributes?.access; + const refreshToken = data?.data?.attributes?.refresh; + + if (!accessToken || !refreshToken) { + throw new Error("Missing tokens in switch tenant response"); + } + + return { success: true, accessToken, refreshToken }; + } catch (error) { + return handleApiError(error); + } +} + +const createTenantSchema = z.object({ + name: z + .string() + .trim() + .min(1, { message: "Name is required" }) + .max(100, { message: "Name must be 100 characters or less" }), +}); + +interface CreateTenantSuccess { + success: true; + tenantId: string; +} + +interface CreateTenantError { + error: string; +} + +export type CreateTenantState = CreateTenantSuccess | CreateTenantError; + +export async function createTenant( + _prevState: CreateTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = createTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + const fieldErrors = validatedData.error.flatten().fieldErrors; + return { error: fieldErrors?.name?.[0] || "Invalid input" }; + } + + const { name } = validatedData.data; + const headers = await getAuthHeaders({ contentType: true }); + + const payload = { + data: { + type: "tenants", + attributes: { name }, + }, + }; + + try { + const url = new URL(`${apiBaseUrl}/tenants`); + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to create tenant: ${response.statusText}`; + throw new Error(errorDetail); + } + + const data = await response.json(); + const tenantId = data?.data?.id; + + if (!tenantId) { + throw new Error("Missing tenant ID in create response"); + } + + revalidatePath("/profile"); + return { success: true, tenantId }; + } catch (error) { + return handleApiError(error); + } +} + +const deleteTenantSchema = z.object({ + tenantId: z.uuid(), +}); + +const switchThenDeleteTenantSchema = z.object({ + tenantId: z.uuid(), + targetTenantId: z.uuid(), +}); + +interface DeleteTenantSuccess { + success: true; +} + +interface DeleteTenantError { + error: string; +} + +export type DeleteTenantState = DeleteTenantSuccess | DeleteTenantError; + +export async function deleteTenant( + _prevState: DeleteTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = deleteTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + return { error: "Invalid tenant ID" }; + } + + const { tenantId } = validatedData.data; + const headers = await getAuthHeaders({ contentType: false }); + + try { + const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`); + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to delete tenant: ${response.statusText}`; + throw new Error(errorDetail); + } + + revalidatePath("/profile"); + return { success: true }; + } catch (error) { + return handleApiError(error); + } +} + +interface SwitchThenDeleteSuccess { + success: true; + accessToken: string; + refreshToken: string; +} + +interface SwitchThenDeleteError { + error: string; + accessToken?: string; + refreshToken?: string; +} + +export type SwitchThenDeleteTenantState = + | SwitchThenDeleteSuccess + | SwitchThenDeleteError; + +export async function switchThenDeleteTenant( + _prevState: SwitchThenDeleteTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = switchThenDeleteTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + return { error: "Invalid tenant or target tenant ID" }; + } + + const { tenantId, targetTenantId } = validatedData.data; + const headers = await getAuthHeaders({ contentType: true }); + + // Step 1: Switch to the target tenant (current token is still valid) + const switchPayload = { + data: { + type: "tokens-switch-tenant", + attributes: { + tenant_id: targetTenantId, + }, + }, + }; + + let newAccessToken: string; + let newRefreshToken: string; + + try { + const switchUrl = new URL(`${apiBaseUrl}/tokens/switch`); + const switchResponse = await fetch(switchUrl.toString(), { + method: "POST", + headers, + body: JSON.stringify(switchPayload), + }); + + if (!switchResponse.ok) { + const errorData = await switchResponse.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to switch tenant: ${switchResponse.statusText}`; + throw new Error(errorDetail); + } + + const switchData = await switchResponse.json(); + newAccessToken = switchData?.data?.attributes?.access; + newRefreshToken = switchData?.data?.attributes?.refresh; + + if (!newAccessToken || !newRefreshToken) { + throw new Error("Missing tokens in switch tenant response"); + } + } catch (error) { + return handleApiError(error); + } + + // Step 2: Delete the old tenant using the NEW token + const deleteHeaders: Record = { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${newAccessToken}`, + }; + + try { + const deleteUrl = new URL(`${apiBaseUrl}/tenants/${tenantId}`); + const deleteResponse = await fetch(deleteUrl.toString(), { + method: "DELETE", + headers: deleteHeaders, + }); + + if (!deleteResponse.ok) { + const errorData = await deleteResponse.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to delete tenant: ${deleteResponse.statusText}`; + // Switch succeeded but delete failed — return tokens so client can still update session + return { + error: errorDetail, + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }; + } + + revalidatePath("/profile"); + return { + success: true, + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }; + } catch (error) { + // Switch succeeded but delete threw — return tokens so client can still update session + const errorResult = handleApiError(error); + return { + ...errorResult, + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }; + } +} diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index 0e3845787e..441ad28697 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -10,12 +10,15 @@ import { CloudflareProviderBadge, GCPProviderBadge, GitHubProviderBadge, + GoogleWorkspaceProviderBadge, IacProviderBadge, + ImageProviderBadge, KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, OpenStackProviderBadge, OracleCloudProviderBadge, + VercelProviderBadge, } from "@/components/icons/providers-badge"; import { MultiSelect, @@ -25,7 +28,11 @@ import { MultiSelectValue, } from "@/components/shadcn/select/multiselect"; import { useUrlFilters } from "@/hooks/use-url-filters"; -import type { ProviderProps, ProviderType } from "@/types/providers"; +import { + getProviderDisplayName, + type ProviderProps, + type ProviderType, +} from "@/types/providers"; const PROVIDER_ICON: Record = { aws: , @@ -34,68 +41,84 @@ const PROVIDER_ICON: Record = { kubernetes: , m365: , github: , + googleworkspace: , iac: , + image: , oraclecloud: , mongodbatlas: , alibabacloud: , cloudflare: , openstack: , + vercel: , }; -interface AccountsSelectorProps { +/** Common props shared by both batch and instant modes. */ +interface AccountsSelectorBaseProps { providers: ProviderProps[]; + /** + * Currently selected provider types (from the pending ProviderTypeSelector state). + * Used only for contextual description/empty-state messaging — does NOT narrow + * the list of available accounts, which remains independent of provider selection. + */ + selectedProviderTypes?: string[]; } -export function AccountsSelector({ providers }: AccountsSelectorProps) { +/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */ +interface AccountsSelectorBatchProps extends AccountsSelectorBaseProps { + /** + * Called instead of navigating immediately. + * Use this on pages that batch filter changes (e.g. Findings). + * + * @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_id__in" + * @param values - The selected values array + */ + onBatchChange: (filterKey: string, values: string[]) => void; + /** + * Pending selected values controlled by the parent. + * Reflects pending state before Apply is clicked. + */ + selectedValues: string[]; +} + +/** Instant mode: URL-driven — neither callback nor controlled value. */ +interface AccountsSelectorInstantProps extends AccountsSelectorBaseProps { + onBatchChange?: never; + selectedValues?: never; +} + +type AccountsSelectorProps = + | AccountsSelectorBatchProps + | AccountsSelectorInstantProps; + +export function AccountsSelector({ + providers, + onBatchChange, + selectedValues, + selectedProviderTypes, +}: AccountsSelectorProps) { const searchParams = useSearchParams(); const { navigateWithParams } = useUrlFilters(); const filterKey = "filter[provider_id__in]"; const current = searchParams.get(filterKey) || ""; - const selectedTypes = searchParams.get("filter[provider_type__in]") || ""; - const selectedTypesList = selectedTypes - ? selectedTypes.split(",").filter(Boolean) - : []; - const selectedIds = current ? current.split(",").filter(Boolean) : []; - const visibleProviders = providers - // .filter((p) => p.attributes.connection?.connected) - .filter((p) => - selectedTypesList.length > 0 - ? selectedTypesList.includes(p.attributes.provider) - : true, - ); + const urlSelectedIds = current ? current.split(",").filter(Boolean) : []; + + // In batch mode, use the parent-controlled pending values; otherwise, use URL state. + const selectedIds = onBatchChange ? selectedValues : urlSelectedIds; + const visibleProviders = providers; + // .filter((p) => p.attributes.connection?.connected) const handleMultiValueChange = (ids: string[]) => { + if (onBatchChange) { + onBatchChange("provider_id__in", ids); + return; + } navigateWithParams((params) => { params.delete(filterKey); if (ids.length > 0) { params.set(filterKey, ids.join(",")); } - - // Auto-deselect provider types that no longer have any selected accounts - if (selectedTypesList.length > 0) { - // Get provider types of currently selected accounts - const selectedProviders = providers.filter((p) => ids.includes(p.id)); - const selectedProviderTypes = new Set( - selectedProviders.map((p) => p.attributes.provider), - ); - - // Keep only provider types that still have selected accounts - const remainingProviderTypes = selectedTypesList.filter((type) => - selectedProviderTypes.has(type as ProviderType), - ); - - // Update provider_type__in filter - if (remainingProviderTypes.length > 0) { - params.set( - "filter[provider_type__in]", - remainingProviderTypes.join(","), - ); - } else { - params.delete("filter[provider_type__in]"); - } - } }); }; @@ -111,9 +134,12 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) { ); }; + // Build a contextual description based on currently selected provider types. + // This is purely for user guidance (aria label + empty state) and does NOT + // narrow the list of available accounts — all providers remain selectable. const filterDescription = - selectedTypesList.length > 0 - ? `Showing accounts for ${selectedTypesList.join(", ")} providers` + selectedProviderTypes && selectedProviderTypes.length > 0 + ? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}` : "All connected cloud provider accounts"; return ( @@ -172,8 +198,8 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) { ) : (
    - {selectedTypesList.length > 0 - ? "No accounts available for selected providers" + {selectedProviderTypes && selectedProviderTypes.length > 0 + ? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}` : "No connected accounts available"}
    )} diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index a1aaeaa6e5..849d9e4d5e 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -1,7 +1,7 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { lazy, Suspense } from "react"; +import { type ComponentType, lazy, Suspense } from "react"; import { MultiSelect, @@ -48,6 +48,11 @@ const IacProviderBadge = lazy(() => default: m.IacProviderBadge, })), ); +const ImageProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.ImageProviderBadge, + })), +); const OracleCloudProviderBadge = lazy(() => import("@/components/icons/providers-badge").then((m) => ({ default: m.OracleCloudProviderBadge, @@ -73,6 +78,16 @@ const OpenStackProviderBadge = lazy(() => default: m.OpenStackProviderBadge, })), ); +const GoogleWorkspaceProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GoogleWorkspaceProviderBadge, + })), +); +const VercelProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.VercelProviderBadge, + })), +); type IconProps = { width: number; height: number }; @@ -82,7 +97,7 @@ const IconPlaceholder = ({ width, height }: IconProps) => ( const PROVIDER_DATA: Record< ProviderType, - { label: string; icon: React.ComponentType } + { label: string; icon: ComponentType } > = { aws: { label: "Amazon Web Services", @@ -108,10 +123,18 @@ const PROVIDER_DATA: Record< label: "GitHub", icon: GitHubProviderBadge, }, + googleworkspace: { + label: "Google Workspace", + icon: GoogleWorkspaceProviderBadge, + }, iac: { label: "Infrastructure as Code", icon: IacProviderBadge, }, + image: { + label: "Container Registry", + icon: ImageProviderBadge, + }, oraclecloud: { label: "Oracle Cloud Infrastructure", icon: OracleCloudProviderBadge, @@ -132,24 +155,66 @@ const PROVIDER_DATA: Record< label: "OpenStack", icon: OpenStackProviderBadge, }, + vercel: { + label: "Vercel", + icon: VercelProviderBadge, + }, }; -type ProviderTypeSelectorProps = { +/** Common props shared by both batch and instant modes. */ +interface ProviderTypeSelectorBaseProps { providers: ProviderProps[]; -}; +} + +/** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */ +interface ProviderTypeSelectorBatchProps extends ProviderTypeSelectorBaseProps { + /** + * Called instead of navigating immediately. + * Use this on pages that batch filter changes (e.g. Findings). + * + * @param filterKey - The raw filter key without "filter[]" wrapper, e.g. "provider_type__in" + * @param values - The selected values array + */ + onBatchChange: (filterKey: string, values: string[]) => void; + /** + * Pending selected values controlled by the parent. + * Reflects pending state before Apply is clicked. + */ + selectedValues: string[]; +} + +/** Instant mode: URL-driven — neither callback nor controlled value. */ +interface ProviderTypeSelectorInstantProps + extends ProviderTypeSelectorBaseProps { + onBatchChange?: never; + selectedValues?: never; +} + +type ProviderTypeSelectorProps = + | ProviderTypeSelectorBatchProps + | ProviderTypeSelectorInstantProps; export const ProviderTypeSelector = ({ providers, + onBatchChange, + selectedValues, }: ProviderTypeSelectorProps) => { const searchParams = useSearchParams(); const { navigateWithParams } = useUrlFilters(); const currentProviders = searchParams.get("filter[provider_type__in]") || ""; - const selectedTypes = currentProviders + const urlSelectedTypes = currentProviders ? currentProviders.split(",").filter(Boolean) : []; + // In batch mode, use the parent-controlled pending values; otherwise, use URL state. + const selectedTypes = onBatchChange ? selectedValues : urlSelectedTypes; + const handleMultiValueChange = (values: string[]) => { + if (onBatchChange) { + onBatchChange("provider_type__in", values); + return; + } navigateWithParams((params) => { // Update provider_type__in if (values.length > 0) { @@ -157,10 +222,6 @@ export const ProviderTypeSelector = ({ } else { params.delete("filter[provider_type__in]"); } - - // Clear account selection when changing provider types - // User should manually select accounts if they want to filter by specific accounts - params.delete("filter[provider_id__in]"); }); }; @@ -170,7 +231,11 @@ export const ProviderTypeSelector = ({ // .filter((p) => p.attributes.connection?.connected) .map((p) => p.attributes.provider), ), - ).filter((type): type is ProviderType => type in PROVIDER_DATA); + ) + .filter((type): type is ProviderType => type in PROVIDER_DATA) + .sort((a, b) => + PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label), + ); const renderIcon = (providerType: ProviderType) => { const IconComponent = PROVIDER_DATA[providerType].icon; @@ -186,14 +251,14 @@ export const ProviderTypeSelector = ({ if (selectedTypes.length === 1) { const providerType = selectedTypes[0] as ProviderType; return ( - + {renderIcon(providerType)} - {PROVIDER_DATA[providerType].label} + {PROVIDER_DATA[providerType].label} ); } return ( - + {selectedTypes.length} providers selected ); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx b/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx index 9e1f3684ca..101f97d5b7 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/_components/workflow-attack-paths.tsx @@ -12,7 +12,7 @@ export const WorkflowAttackPaths = () => { const pathname = usePathname(); // Determine current step based on pathname - const isQueryBuilderStep = pathname.includes("query-builder"); + const isQueryBuilderStep = pathname.includes("/attack-paths"); const currentStep = isQueryBuilderStep ? 1 : 0; // 0-indexed diff --git a/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx index 8557ab5369..fb9ef2f902 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/layout.tsx @@ -1,21 +1,7 @@ -import { Navbar } from "@/components/ui/nav-bar/navbar"; - -/** - * Workflow layout for Attack Paths - * Displays content with navbar - */ -export default function AttackPathsWorkflowLayout({ +export default function WorkflowLayout({ children, }: { children: React.ReactNode; }) { - return ( - <> - -
    - {/* Content */} -
    {children}
    -
    - - ); + return <>{children}; } diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx new file mode 100644 index 0000000000..a87fb546b2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { GraphLoading } from "./graph-loading"; + +describe("GraphLoading", () => { + it("uses the provider wizard loading pattern", () => { + render(); + + expect(screen.getByTestId("graph-loading")).toHaveClass( + "flex", + "min-h-[320px]", + "items-center", + "justify-center", + "gap-4", + "text-center", + ); + expect(screen.getByLabelText("Loading")).toHaveClass( + "size-6", + "animate-spin", + ); + expect(screen.getByText("Loading Attack Paths graph...")).toHaveClass( + "text-muted-foreground", + "text-sm", + ); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx index cf56231a05..7b5645dfdf 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-loading.tsx @@ -1,6 +1,6 @@ "use client"; -import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; +import { Spinner } from "@/components/shadcn/spinner/spinner"; /** * Loading skeleton for graph visualization @@ -8,17 +8,14 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; */ export const GraphLoading = () => { return ( -
    -
    -
    - - - -
    -

    - Loading Attack Paths graph... -

    -
    +
    + +

    + Loading Attack Paths graph... +

    ); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts index eac86fccc7..83161cc4ef 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/index.ts @@ -1,6 +1,8 @@ export { ExecuteButton } from "./execute-button"; export * from "./graph"; export * from "./node-detail"; +export { QueryDescription } from "./query-description"; +export { QueryExecutionError } from "./query-execution-error"; export { QueryParametersForm } from "./query-parameters-form"; export { QuerySelector } from "./query-selector"; export { ScanListTable } from "./scan-list-table"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.test.tsx new file mode 100644 index 0000000000..b65591414d --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { QueryCodeEditor } from "./query-code-editor"; + +describe("QueryCodeEditor", () => { + it("renders a code editor surface with the compact read-only badge", () => { + // Given + render( + {}} + />, + ); + + // When + const editor = screen.getByRole("textbox", { name: /opencypher/i }); + + // Then + expect(editor).toHaveAttribute("contenteditable", "true"); + expect(screen.getByText("openCypher")).toBeInTheDocument(); + expect(screen.getByText("Read-only*")).toBeInTheDocument(); + expect(screen.getByText("MATCH (n) RETURN n LIMIT 25")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("propagates content changes and exposes the invalid state in the container", async () => { + // Given + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + // When + await user.type(screen.getByRole("textbox", { name: /opencypher/i }), "A"); + + // Then + expect(onChange).toHaveBeenCalled(); + expect(screen.getByTestId("query-code-editor")).toHaveClass( + "border-border-error-primary", + ); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.tsx new file mode 100644 index 0000000000..0f0866c1a2 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-code-editor.tsx @@ -0,0 +1,399 @@ +"use client"; + +import { + HighlightStyle, + StreamLanguage, + syntaxHighlighting, +} from "@codemirror/language"; +import { tags } from "@lezer/highlight"; +import CodeMirror, { + EditorView, + highlightActiveLineGutter, + lineNumbers, + placeholder as codeEditorPlaceholder, +} from "@uiw/react-codemirror"; +import { useTheme } from "next-themes"; +import { type HTMLAttributes } from "react"; + +import { Badge } from "@/components/shadcn"; +import { cn } from "@/lib/utils"; + +const OPEN_CYPHER_KEYWORDS = new Set([ + "all", + "and", + "as", + "asc", + "ascending", + "by", + "call", + "case", + "contains", + "create", + "delete", + "desc", + "descending", + "detach", + "distinct", + "else", + "end", + "exists", + "false", + "in", + "is", + "limit", + "match", + "merge", + "not", + "null", + "optional", + "or", + "order", + "remove", + "return", + "set", + "skip", + "then", + "true", + "unwind", + "where", + "with", + "xor", + "yield", +]); + +const OPEN_CYPHER_FUNCTIONS = new Set([ + "collect", + "coalesce", + "count", + "exists", + "head", + "id", + "keys", + "labels", + "last", + "length", + "nodes", + "properties", + "range", + "reduce", + "relationships", + "size", + "startnode", + "sum", + "tail", + "timestamp", + "tolower", + "toupper", + "trim", + "type", +]); + +interface OpenCypherParserState { + inBlockComment: boolean; + inString: "'" | '"' | null; +} + +const openCypherLanguage = StreamLanguage.define({ + startState() { + return { + inBlockComment: false, + inString: null, + }; + }, + token(stream, state) { + if (state.inBlockComment) { + while (!stream.eol()) { + if (stream.match("*/")) { + state.inBlockComment = false; + break; + } + stream.next(); + } + + return "comment"; + } + + if (state.inString) { + let escaped = false; + + while (!stream.eol()) { + const next = stream.next(); + + if (escaped) { + escaped = false; + continue; + } + + if (next === "\\") { + escaped = true; + continue; + } + + if (next === state.inString) { + state.inString = null; + break; + } + } + + // OpenCypher only supports single-line strings — reset at EOL so an + // unclosed quote does not bleed into subsequent lines. + if (stream.eol()) { + state.inString = null; + } + + return "string"; + } + + if (stream.eatSpace()) { + return null; + } + + if (stream.match("//")) { + stream.skipToEnd(); + return "comment"; + } + + if (stream.match("/*")) { + state.inBlockComment = true; + return "comment"; + } + + const quote = stream.peek(); + if (quote === "'" || quote === '"') { + state.inString = quote; + stream.next(); + return "string"; + } + + if (stream.match(/\$[A-Za-z_][\w]*/)) { + return "variableName"; + } + + if (stream.match(/:[A-Za-z_][\w]*/)) { + return "typeName"; + } + + if (stream.match(/[()[\]{},.;]/)) { + return "punctuation"; + } + + if (stream.match(/[<>!=~|&+\-/*%^]+/)) { + return "operator"; + } + + if (stream.match(/\d+(?:\.\d+)?/)) { + return "number"; + } + + if (stream.match(/[A-Za-z_][\w]*/)) { + const currentValue = stream.current(); + const normalizedValue = currentValue.toLowerCase(); + + if (OPEN_CYPHER_KEYWORDS.has(normalizedValue)) { + return "keyword"; + } + + if ( + OPEN_CYPHER_FUNCTIONS.has(normalizedValue) && + stream.match(/\s*(?=\()/, false) + ) { + return "function"; + } + + return "variableName"; + } + + stream.next(); + return null; + }, +}); + +const lightHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "#0550ae", fontWeight: "600" }, + { tag: tags.string, color: "#0a3069" }, + { tag: tags.number, color: "#8250df" }, + { tag: [tags.typeName, tags.className], color: "#953800" }, + { tag: [tags.variableName, tags.propertyName], color: "#24292f" }, + { tag: tags.function(tags.variableName), color: "#8250df" }, + { tag: tags.operator, color: "#57606a" }, + { tag: tags.comment, color: "#6e7781", fontStyle: "italic" }, + { tag: tags.punctuation, color: "#57606a" }, +]); + +const darkHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "#79c0ff", fontWeight: "600" }, + { tag: tags.string, color: "#a5d6ff" }, + { tag: tags.number, color: "#d2a8ff" }, + { tag: [tags.typeName, tags.className], color: "#ffa657" }, + { tag: [tags.variableName, tags.propertyName], color: "#e6edf3" }, + { tag: tags.function(tags.variableName), color: "#d2a8ff" }, + { tag: tags.operator, color: "#8b949e" }, + { tag: tags.comment, color: "#8b949e", fontStyle: "italic" }, + { tag: tags.punctuation, color: "#8b949e" }, +]); + +const MONO_FONT = + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; + +const baseThemeRules: Record> = { + "&": { + backgroundColor: "transparent", + color: "var(--text-neutral-primary)", + fontFamily: MONO_FONT, + fontSize: "12px", + }, + "&.cm-focused": { + outline: "none", + }, + ".cm-scroller": { + minHeight: "320px", + overflow: "auto", + fontFamily: MONO_FONT, + lineHeight: "1.5rem", + }, + ".cm-content": { + padding: "16px", + caretColor: "var(--text-neutral-primary)", + }, + ".cm-line": { + padding: "0 0 0 8px", + }, + ".cm-gutters": { + backgroundColor: "var(--bg-neutral-secondary)", + color: "var(--text-neutral-tertiary)", + borderRight: "1px solid var(--border-neutral-secondary)", + minWidth: "44px", + }, + ".cm-lineNumbers .cm-gutterElement": { + padding: "0 10px 0 12px", + }, + ".cm-activeLineGutter": { + backgroundColor: "var(--bg-neutral-secondary)", + color: "var(--text-neutral-secondary)", + }, + ".cm-activeLine": { + backgroundColor: "transparent", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "var(--text-neutral-primary)", + }, + ".cm-placeholder": { + color: "var(--text-neutral-tertiary)", + }, +}; + +const LIGHT_SELECTION_BG = "rgba(9, 105, 218, 0.18)"; +const DARK_SELECTION_BG = "rgba(121, 192, 255, 0.18)"; + +const lightTheme = EditorView.theme( + { + ...baseThemeRules, + ".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection": + { backgroundColor: LIGHT_SELECTION_BG }, + }, + { dark: false }, +); + +const darkTheme = EditorView.theme( + { + ...baseThemeRules, + ".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection": + { backgroundColor: DARK_SELECTION_BG }, + }, + { dark: true }, +); + +interface QueryCodeEditorProps + extends Omit, "onChange"> { + ariaLabel: string; + language?: "openCypher"; + value: string; + placeholder?: string; + invalid?: boolean; + requirementBadge?: string; + onChange: (value: string) => void; + onBlur?: () => void; +} + +export const QueryCodeEditor = ({ + id, + className, + ariaLabel, + language = "openCypher", + value, + placeholder, + invalid = false, + requirementBadge, + onChange, + onBlur, + ...props +}: QueryCodeEditorProps) => { + const { resolvedTheme } = useTheme(); + const isDarkMode = resolvedTheme === "dark"; + const editorTheme = isDarkMode ? darkTheme : lightTheme; + const editorHighlightStyle = isDarkMode + ? darkHighlightStyle + : lightHighlightStyle; + + return ( +
    +
    + + {ariaLabel} + + {requirementBadge ? ( + + {requirementBadge} + + ) : null} +
    + + { + onBlur?.(); + return false; + }, + }), + ]} + /> +
    + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx new file mode 100644 index 0000000000..cc61c16a4d --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { AttackPathQuery } from "@/types/attack-paths"; + +import { QueryDescription } from "./query-description"; + +const customQuery: AttackPathQuery = { + type: "attack-paths-scans", + id: "custom-query", + attributes: { + name: "Custom openCypher query", + short_description: "Write your own query", + description: + "Run a read-only openCypher query against the selected Attack Paths scan.", + provider: "aws", + attribution: null, + documentation_link: { + text: "Cartography schema used by Prowler for AWS graphs", + link: "https://example.com/schema", + }, + parameters: [], + }, +}; + +describe("QueryDescription", () => { + it("renders the schema documentation link inside an info alert", () => { + // Given + render(); + + // When + const alert = screen.getByRole("alert"); + const link = screen.getByRole("link", { + name: /cartography schema used by prowler for aws graphs/i, + }); + + // Then + expect(alert).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "https://example.com/schema"); + }); + + it("does not render unsafe documentation or attribution URLs as clickable links", () => { + // Given + const queryWithUnsafeLinks: AttackPathQuery = { + ...customQuery, + attributes: { + ...customQuery.attributes, + documentation_link: { + text: "Cartography schema used by Prowler for AWS graphs", + link: "javascript:alert('xss')", + }, + attribution: { + text: "Unsafe source", + link: "javascript:alert('xss')", + }, + }, + }; + + // When + render(); + + // Then + expect( + screen.queryByRole("link", { + name: /cartography schema used by prowler for aws graphs/i, + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: /unsafe source/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/cartography schema used by prowler for aws graphs/i), + ).toBeInTheDocument(); + expect(screen.getByText(/unsafe source/i)).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx new file mode 100644 index 0000000000..d1cb6e85fe --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-description.tsx @@ -0,0 +1,70 @@ +import { Info } from "lucide-react"; + +import { Alert, AlertDescription } from "@/components/shadcn"; +import type { AttackPathQuery } from "@/types/attack-paths"; + +interface QueryDescriptionProps { + query: AttackPathQuery; +} + +const isSafeUrl = (url: string): boolean => { + try { + const parsedUrl = new URL(url); + return parsedUrl.protocol === "https:" || parsedUrl.protocol === "http:"; + } catch { + return false; + } +}; + +export const QueryDescription = ({ query }: QueryDescriptionProps) => { + const documentationLink = query.attributes.documentation_link; + const attribution = query.attributes.attribution; + + return ( + + + +

    {query.attributes.description}

    + + {documentationLink && ( +

    + {isSafeUrl(documentationLink.link) ? ( + + {documentationLink.text} + + ) : ( + {documentationLink.text} + )} +

    + )} + + {attribution && ( +

    + {isSafeUrl(attribution.link) ? ( + <> + Source:{" "} + + {attribution.text} + + + ) : ( + <> + Source: {attribution.text} + + )} +

    + )} + + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx new file mode 100644 index 0000000000..1c7fb29712 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { QueryExecutionError } from "./query-execution-error"; + +describe("QueryExecutionError", () => { + it("renders the default title and the raw query error details without extra copy", () => { + // Given + const error = + "Invalid input 'WHERE': expected 'MATCH' or 'WITH' (line 1, column 1)"; + + // When + render(); + + // Then + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText(/query execution failed/i)).toBeInTheDocument(); + expect( + screen.queryByText(/the attack paths query could not be executed/i), + ).not.toBeInTheDocument(); + expect(screen.getByText(error)).toBeInTheDocument(); + }); + + it("renders custom title and description when provided", () => { + // Given + const error = "Failed to load available queries"; + + // When + render( + , + ); + + // Then + expect(screen.getByText(/failed to load queries/i)).toBeInTheDocument(); + expect( + screen.getByText( + /available attack paths queries could not be loaded for this scan/i, + ), + ).toBeInTheDocument(); + expect(screen.getByText(error)).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx new file mode 100644 index 0000000000..084bcf6e16 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-execution-error.tsx @@ -0,0 +1,30 @@ +import { CircleAlert } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn"; + +interface QueryExecutionErrorProps { + error: string; + title?: string; + description?: string; +} + +export const QueryExecutionError = ({ + error, + title = "Query execution failed", + description, +}: QueryExecutionErrorProps) => { + return ( + + + {title} + + {description ?

    {description}

    : null} +
    +
    +            {error}
    +          
    +
    +
    +
    + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx new file mode 100644 index 0000000000..d8493f4426 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.test.tsx @@ -0,0 +1,188 @@ +import { render, screen } from "@testing-library/react"; +import { useEffect } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { describe, expect, it } from "vitest"; + +import { + ATTACK_PATH_QUERY_IDS, + type AttackPathQuery, +} from "@/types/attack-paths"; + +import { QueryParametersForm } from "./query-parameters-form"; + +const mockQuery: AttackPathQuery = { + type: "attack-paths-scans", + id: "query-with-string-parameter", + attributes: { + name: "Query With String Parameter", + short_description: "Requires a tag key", + description: "Returns buckets filtered by tag", + provider: "aws", + attribution: null, + parameters: [ + { + name: "tag_key", + label: "Tag key", + data_type: "string", + description: "Tag key to filter the S3 bucket.", + placeholder: "DataClassification", + required: true, + }, + ], + }, +}; + +function TestForm() { + const form = useForm({ + defaultValues: { + tag_key: "", + }, + }); + + return ( + + + + ); +} + +function TestCustomQueryForm() { + const customQuery: AttackPathQuery = { + type: "attack-paths-scans", + id: ATTACK_PATH_QUERY_IDS.CUSTOM, + attributes: { + name: "Custom openCypher query", + short_description: "Write your own query", + description: "Run a custom query against the graph.", + provider: "aws", + attribution: null, + parameters: [ + { + name: "query", + label: "openCypher", + data_type: "string", + input_type: "code-editor", + placeholder: "MATCH (n) RETURN n LIMIT 25", + description: "", + required: true, + editor_language: "openCypher", + requirement_badge: "Read-only*", + }, + ], + }, + }; + + const form = useForm({ + defaultValues: { + query: "", + }, + }); + + return ( + + + + ); +} + +function TestFormWithError() { + const form = useForm({ + defaultValues: { + tag_key: "", + }, + }); + + useEffect(() => { + form.setError("tag_key", { + type: "manual", + message: "Tag key is required", + }); + }, [form]); + + return ( + + + + ); +} + +describe("QueryParametersForm", () => { + it("uses the field description as the placeholder instead of rendering helper text below", () => { + // Given + render(); + + // When + const input = screen.getByRole("textbox", { name: /tag key/i }); + + // Then + expect(input).toHaveAttribute("data-slot", "input"); + expect(input).toHaveAttribute( + "placeholder", + "Tag key to filter the S3 bucket.", + ); + expect(screen.getByTestId("query-parameters-grid")).toHaveClass( + "grid", + "grid-cols-1", + "md:grid-cols-2", + ); + expect(screen.getByText("Tag key")).toHaveClass( + "text-text-neutral-tertiary", + "text-xs", + "font-medium", + ); + expect( + screen.queryByText("Tag key to filter the S3 bucket."), + ).not.toBeInTheDocument(); + }); + + it("renders a code editor when the parameter input type is code-editor", () => { + // Given + render(); + + // When + const input = screen.getByRole("textbox", { name: /opencypher/i }); + const codeEditor = screen.getByTestId("query-code-editor"); + + // Then + expect(input.tagName).toBe("DIV"); + expect(input).toHaveAttribute("contenteditable", "true"); + expect(codeEditor).toHaveAttribute("data-language", "openCypher"); + expect(codeEditor).toHaveClass( + "rounded-xl", + "border", + "bg-bg-neutral-primary", + ); + expect(screen.getByText("Read-only*")).toBeInTheDocument(); + expect(screen.getByText("openCypher")).toBeInTheDocument(); + expect(screen.queryByText("openCypher*")).not.toBeInTheDocument(); + expect(screen.queryByText("Read-only")).not.toBeInTheDocument(); + }); + + it("uses the design-system error token for field validation messages", async () => { + // Given + render(); + + // When + const errorMessage = await screen.findByText("Tag key is required"); + + // Then + expect(errorMessage).toHaveClass("text-text-error-primary", "text-xs"); + }); + + it("connects field errors to the input for accessibility", async () => { + // Given + render(); + + // When + const input = screen.getByRole("textbox", { name: /tag key/i }); + const errorMessage = await screen.findByText("Tag key is required"); + + // Then + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(errorMessage).toHaveAttribute("id"); + expect(input).toHaveAttribute( + "aria-describedby", + expect.stringContaining(errorMessage.getAttribute("id") ?? ""), + ); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx index ccbcf60547..94edbf2a68 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/query-parameters-form.tsx @@ -1,8 +1,22 @@ "use client"; -import { Controller, useFormContext } from "react-hook-form"; +import { useFormContext } from "react-hook-form"; -import type { AttackPathQuery } from "@/types/attack-paths"; +import { Input, Textarea } from "@/components/shadcn"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { cn } from "@/lib/utils"; +import { + type AttackPathQuery, + QUERY_PARAMETER_INPUT_TYPES, +} from "@/types/attack-paths"; + +import { QueryCodeEditor } from "./query-code-editor"; interface QueryParametersFormProps { selectedQuery: AttackPathQuery | null | undefined; @@ -15,20 +29,10 @@ interface QueryParametersFormProps { export const QueryParametersForm = ({ selectedQuery, }: QueryParametersFormProps) => { - const { - control, - formState: { errors }, - } = useFormContext(); + const { control } = useFormContext(); if (!selectedQuery || !selectedQuery.attributes.parameters.length) { - return ( -
    -

    - This query requires no parameters. Click "Execute Query" to - proceed. -

    -
    - ); + return null; } return ( @@ -37,86 +41,114 @@ export const QueryParametersForm = ({ Query Parameters - {selectedQuery.attributes.parameters.map((param) => ( - { - if (param.data_type === "boolean") { - return ( -
    - + + + ); } - return undefined; - })(); - const descriptionId = `${param.name}-description`; - return ( -
    - - - {param.description && ( - - {param.description} - - )} - {errorMessage && ( - {errorMessage} - )} -
    - ); - }} - /> - ))} + {!isCodeEditor && ( + + {param.label} + {param.required && ( + * + )} + + )} + {isCodeEditor ? ( + + + + ) : ( + + {isTextarea ? ( +