diff --git a/.env b/.env index c6085538a0..35417637da 100644 --- a/.env +++ b/.env @@ -6,14 +6,20 @@ PROWLER_UI_VERSION="stable" AUTH_URL=http://localhost:3000 API_BASE_URL=http://prowler-api:8080/api/v1 +# deprecated, use UI_API_BASE_URL NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL} +UI_API_BASE_URL=${API_BASE_URL} +# deprecated, use UI_API_DOCS_URL NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs +UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST=true UI_PORT=3000 # openssl rand -base64 32 AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8=" -# Google Tag Manager ID +# Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress) +# deprecated, use UI_GOOGLE_TAG_MANAGER_ID NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID="" +UI_GOOGLE_TAG_MANAGER_ID="" #### MCP Server #### PROWLER_MCP_VERSION=stable @@ -139,13 +145,19 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute -# Sentry settings -SENTRY_ENVIRONMENT=local +# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒ +# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's +# server/edge SDKs. +UI_SENTRY_DSN= +UI_SENTRY_ENVIRONMENT=local SENTRY_RELEASE=local -NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} +# Reserved runtime public config (registered now; no UI consumer yet) +# POSTHOG_KEY= +# POSTHOG_HOST= +# REO_DEV_CLIENT_ID= #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3300394d83..ed97ff5a1a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,6 @@ # SDK /* @prowler-cloud/detection-remediation /prowler/ @prowler-cloud/detection-remediation -/prowler/compliance/ @prowler-cloud/compliance /tests/ @prowler-cloud/detection-remediation /dashboard/ @prowler-cloud/detection-remediation /docs/ @prowler-cloud/detection-remediation diff --git a/.github/actions/osv-scanner/action.yml b/.github/actions/osv-scanner/action.yml index 4bfb5993cb..de5116fdcf 100644 --- a/.github/actions/osv-scanner/action.yml +++ b/.github/actions/osv-scanner/action.yml @@ -1,5 +1,5 @@ name: 'OSV-Scanner' -description: 'Install osv-scanner and scan a lockfile, failing on HIGH/CRITICAL/UNKNOWN severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).' +description: 'Install osv-scanner and scan a lockfile, failing on CRITICAL severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).' author: 'Prowler' inputs: @@ -7,9 +7,9 @@ inputs: description: 'Path to the lockfile to scan, relative to the repository root (e.g. uv.lock, api/uv.lock, ui/pnpm-lock.yaml).' required: true severity-levels: - description: 'Comma-separated severity levels that fail the scan. Default: HIGH,CRITICAL,UNKNOWN.' + description: 'Comma-separated severity levels that fail the scan. Default: CRITICAL.' required: false - default: 'HIGH,CRITICAL,UNKNOWN' + default: 'CRITICAL' version: description: 'osv-scanner release tag to install. When overriding, you MUST also override binary-sha256.' required: false diff --git a/.github/actions/setup-python-uv/action.yml b/.github/actions/setup-python-uv/action.yml index e23388d86e..14b9b81d15 100644 --- a/.github/actions/setup-python-uv/action.yml +++ b/.github/actions/setup-python-uv/action.yml @@ -43,8 +43,17 @@ runs: if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + LATEST_COMMIT=$(curl -sf \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ + | jq -er '.sha') || { + echo "::error::Failed to fetch latest prowler/master commit from the GitHub API (HTTP error or missing .sha). Check the GITHUB_TOKEN and API rate limits." + exit 1 + } echo "Latest commit hash: $LATEST_COMMIT" sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock echo "Updated uv.lock entry:" @@ -54,8 +63,17 @@ runs: if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + LATEST_COMMIT=$(curl -sf \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ + | jq -er '.sha') || { + echo "::error::Failed to fetch latest prowler/master commit from the GitHub API (HTTP error or missing .sha). Check the GITHUB_TOKEN and API rate limits." + exit 1 + } echo "Latest commit hash: $LATEST_COMMIT" sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock echo "Updated uv.lock entry:" diff --git a/.github/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index 9073e2fbcb..45034ddd6a 100644 --- a/.github/actions/trivy-scan/action.yml +++ b/.github/actions/trivy-scan/action.yml @@ -63,7 +63,7 @@ runs: exit-code: '0' scanners: 'vuln' timeout: '5m' - version: 'v0.69.2' + version: 'v0.71.0' - name: Run Trivy vulnerability scan (SARIF) if: inputs.upload-sarif == 'true' && github.event_name == 'push' @@ -76,7 +76,7 @@ runs: exit-code: '0' scanners: 'vuln' timeout: '5m' - version: 'v0.69.2' + version: 'v0.71.0' - name: Upload Trivy results to GitHub Security tab if: inputs.upload-sarif == 'true' && github.event_name == 'push' diff --git a/.github/labeler.yml b/.github/labeler.yml index 1b0dfd0400..b9abb1dfd0 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -77,6 +77,11 @@ provider/okta: - any-glob-to-any-file: "prowler/providers/okta/**" - any-glob-to-any-file: "tests/providers/okta/**" +provider/linode: + - changed-files: + - any-glob-to-any-file: "prowler/providers/linode/**" + - any-glob-to-any-file: "tests/providers/linode/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" diff --git a/.github/scripts/osv-scan.sh b/.github/scripts/osv-scan.sh index d9c2e1a901..16afc6668c 100755 --- a/.github/scripts/osv-scan.sh +++ b/.github/scripts/osv-scan.sh @@ -6,8 +6,7 @@ # - .github/workflows/api-security.yml, sdk-security.yml, ui-security.yml # # Severity levels (comma-separated) are read from OSV_SEVERITY_LEVELS. -# Default: HIGH,CRITICAL,UNKNOWN — preserves prior .safety-policy.yml policy -# (ignore-cvss-severity-below: 7 + ignore-cvss-unknown-severity: False). +# Default: CRITICAL — only CVSS >= 9.0 findings fail the scan. # osv-scanner has no native CVSS threshold (google/osv-scanner#1400, closed # not-planned). Severity is derived from $group.max_severity (numeric CVSS # score string) which osv-scanner emits per group. @@ -33,7 +32,7 @@ set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" CONFIG="${ROOT}/osv-scanner.toml" -SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-HIGH,CRITICAL,UNKNOWN}" +SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-CRITICAL}" for bin in osv-scanner jq; do if ! command -v "${bin}" >/dev/null 2>&1; then diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index 44482e2428..d12bf5d2d4 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -12,9 +12,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -134,5 +131,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index ba7f5fe58a..ed4e5476d2 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -16,13 +16,6 @@ on: branches: - "master" - "v5.*" - paths: - - 'api/**' - - '.github/workflows/api-tests.yml' - - '.github/workflows/api-security.yml' - - '.github/actions/setup-python-uv/**' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index 7493e1b763..23e6ba2984 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -12,9 +12,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'mcp_server/**' - - '.github/workflows/mcp-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -127,5 +124,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/mcp-security.yml b/.github/workflows/mcp-security.yml index 66a5cd8c0d..4deb6a478d 100644 --- a/.github/workflows/mcp-security.yml +++ b/.github/workflows/mcp-security.yml @@ -15,12 +15,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'mcp_server/pyproject.toml' - - 'mcp_server/uv.lock' - - '.github/workflows/mcp-security.yml' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -30,7 +24,6 @@ permissions: {} jobs: mcp-security-scans: - if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/.github/workflows/sdk-code-quality.yml b/.github/workflows/sdk-code-quality.yml index 5c76547989..2b1efc69a9 100644 --- a/.github/workflows/sdk-code-quality.yml +++ b/.github/workflows/sdk-code-quality.yml @@ -29,6 +29,7 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' steps: - name: Harden Runner diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 42709ac6f7..2b436db468 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -15,12 +15,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'Dockerfile*' - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/sdk-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -111,25 +105,14 @@ jobs: id: check-changes uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: - files: ./** + files: | + prowler/** + Dockerfile* + pyproject.toml + uv.lock + .github/workflows/sdk-container-checks.yml files_ignore: | - .github/** prowler/CHANGELOG.md - docs/** - permissions/** - api/** - ui/** - dashboard/** - mcp_server/** - skills/** - README.md - mkdocs.yml - .backportrc.json - .env - docker-compose* - examples/** - .gitignore - contrib/** **/AGENTS.md - name: Set up Docker Buildx @@ -153,5 +136,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/sdk-security.yml b/.github/workflows/sdk-security.yml index c18f56ea8f..11a7894ce8 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -19,16 +19,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/sdk-tests.yml' - - '.github/workflows/sdk-security.yml' - - '.github/actions/setup-python-uv/**' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -71,27 +61,18 @@ jobs: id: check-changes uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: - files: - ./** + files: | + prowler/** + tests/** + pyproject.toml + uv.lock + .github/workflows/sdk-tests.yml .github/workflows/sdk-security.yml + .github/actions/setup-python-uv/** + .github/actions/osv-scanner/** + .github/scripts/osv-scan.sh files_ignore: | - .github/** prowler/CHANGELOG.md - docs/** - permissions/** - api/** - ui/** - dashboard/** - mcp_server/** - skills/** - README.md - mkdocs.yml - .backportrc.json - .env - docker-compose* - examples/** - .gitignore - contrib/** **/AGENTS.md - name: Setup Python with uv diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 59316c0d95..4fc6cab0b7 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -29,6 +29,7 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' steps: - name: Harden Runner @@ -540,7 +541,7 @@ jobs: with: flags: prowler-py${{ matrix.python-version }}-vercel files: ./vercel_coverage.xml - + # Scaleway Provider - name: Check if Scaleway files changed if: steps.check-changes.outputs.any_changed == 'true' @@ -588,7 +589,31 @@ jobs: with: flags: prowler-py${{ matrix.python-version }}-stackit files: ./stackit_coverage.xml - + + # Linode Provider + - name: Check if Linode files changed + if: steps.check-changes.outputs.any_changed == 'true' + id: changed-linode + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 + with: + files: | + ./prowler/**/linode/** + ./tests/**/linode/** + ./uv.lock + + - name: Run Linode tests + if: steps.changed-linode.outputs.any_changed == 'true' + run: uv run pytest -n auto --cov=./prowler/providers/linode --cov-report=xml:linode_coverage.xml tests/providers/linode + + - name: Upload Linode coverage to Codecov + if: steps.changed-linode.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 }}-linode + files: ./linode_coverage.xml + # External Provider (dynamic loading) - name: Check if External Provider files changed if: steps.check-changes.outputs.any_changed == 'true' @@ -608,14 +633,14 @@ jobs: - name: Upload External Provider coverage to Codecov if: steps.changed-external.outputs.any_changed == 'true' - + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: flags: prowler-py${{ matrix.python-version }}-external files: ./external_coverage.xml - + # Lib - name: Check if Lib files changed if: steps.check-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/ui-container-build-push.yml b/.github/workflows/ui-container-build-push.yml index 984256af7f..3210f6e998 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -32,9 +32,6 @@ env: PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-ui - # Build args - NEXT_PUBLIC_API_BASE_URL: http://prowler-api:8080/api/v1 - permissions: {} jobs: @@ -146,7 +143,6 @@ jobs: context: ${{ env.WORKING_DIRECTORY }} build-args: | NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }} - NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }} push: true platforms: ${{ matrix.platform }} tags: | diff --git a/.github/workflows/ui-container-checks.yml b/.github/workflows/ui-container-checks.yml index 0eae703fce..a931e50f22 100644 --- a/.github/workflows/ui-container-checks.yml +++ b/.github/workflows/ui-container-checks.yml @@ -12,9 +12,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'ui/**' - - '.github/workflows/ui-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -132,5 +129,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index 5e50adb19c..7165882d62 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -40,7 +40,8 @@ jobs: AUTH_SECRET: 'fallback-ci-secret-for-testing' AUTH_TRUST_HOST: true NEXTAUTH_URL: 'http://localhost:3000' - NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1' + AUTH_URL: 'http://localhost:3000' + UI_API_BASE_URL: 'http://localhost:8080/api/v1' E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }} E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }} @@ -77,6 +78,14 @@ jobs: E2E_ALIBABACLOUD_ACCESS_KEY_ID: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_ID }} E2E_ALIBABACLOUD_ACCESS_KEY_SECRET: ${{ secrets.E2E_ALIBABACLOUD_ACCESS_KEY_SECRET }} E2E_ALIBABACLOUD_ROLE_ARN: ${{ secrets.E2E_ALIBABACLOUD_ROLE_ARN }} + E2E_OKTA_DOMAIN: ${{ secrets.E2E_OKTA_DOMAIN }} + E2E_OKTA_CLIENT_ID: ${{ secrets.E2E_OKTA_CLIENT_ID }} + E2E_OKTA_BASE64_PRIVATE_KEY: ${{ secrets.E2E_OKTA_BASE64_PRIVATE_KEY }} + E2E_GOOGLEWORKSPACE_CUSTOMER_ID: ${{ secrets.E2E_GOOGLEWORKSPACE_CUSTOMER_ID }} + E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON: ${{ secrets.E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON }} + E2E_GOOGLEWORKSPACE_DELEGATED_USER: ${{ secrets.E2E_GOOGLEWORKSPACE_DELEGATED_USER }} + E2E_VERCEL_TEAM_ID: ${{ secrets.E2E_VERCEL_TEAM_ID }} + E2E_VERCEL_API_TOKEN: ${{ secrets.E2E_VERCEL_API_TOKEN }} # Pass E2E paths from impact analysis E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }} RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }} @@ -134,7 +143,17 @@ jobs: # docker-compose.yml references prowlercloud/prowler-api:latest from the registry, # which lags behind PR changes; build locally so E2E exercises the API image # produced by this PR. - run: docker build -t prowlercloud/prowler-api:latest ./api + # + # The image installs the SDK from git@master (api/uv.lock), so a PR changing BOTH the SDK + # and the API would run against the OLD SDK and crash on startup. Overlay the checkout's + # SDK source so both run together. New SDK dependencies still need an api/uv.lock bump. + run: | + docker build -t prowlercloud/prowler-api:pr-base ./api + docker build -t prowlercloud/prowler-api:latest -f - prowler <<'DOCKERFILE' + FROM prowlercloud/prowler-api:pr-base + RUN rm -rf /home/prowler/.venv/lib/python3.12/site-packages/prowler + COPY --chown=prowler:prowler . /home/prowler/.venv/lib/python3.12/site-packages/prowler + DOCKERFILE - name: Start API services run: | @@ -147,7 +166,7 @@ jobs: timeout=150 elapsed=0 while [ $elapsed -lt $timeout ]; do - if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then + if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then echo "Prowler API is ready!" exit 0 fi diff --git a/.github/workflows/ui-security.yml b/.github/workflows/ui-security.yml index 470fae1f9c..2dc444654f 100644 --- a/.github/workflows/ui-security.yml +++ b/.github/workflows/ui-security.yml @@ -15,12 +15,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'ui/package.json' - - 'ui/pnpm-lock.yaml' - - '.github/workflows/ui-security.yml' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -30,7 +24,6 @@ permissions: {} jobs: ui-security-scans: - if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 918755a629..2dc50a26bb 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -131,6 +131,10 @@ jobs: if: steps.check-changes.outputs.any_changed == 'true' run: pnpm run healthcheck + - name: Check product-tour alignment + if: steps.check-changes.outputs.any_changed == 'true' + run: pnpm run tour:check + - name: Run pnpm audit if: steps.check-changes.outputs.any_changed == 'true' run: pnpm run audit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4e79141bb7..1e9d74c973 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,6 +7,10 @@ # P50 — dependency validation default_install_hook_types: [pre-commit] +# Hooks run on commit only by default; +# NOTE: default_stages does NOT override a hook's manifest stages, so fixers shipping pre-push in their +# manifest need an explicit stages: ["pre-commit"] below to stay off push. +default_stages: [pre-commit] repos: ## GENERAL (prek built-in — no external repo needed) @@ -21,13 +25,16 @@ repos: - id: check-json priority: 10 - id: end-of-file-fixer + stages: ["pre-commit"] priority: 0 - id: trailing-whitespace + stages: ["pre-commit"] priority: 0 - id: no-commit-to-branch priority: 10 - id: pretty-format-json args: ["--autofix", --no-sort-keys, --no-ensure-ascii] + stages: ["pre-commit"] priority: 10 ## TOML @@ -82,6 +89,7 @@ repos: name: "SDK - isort" files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] } args: ["--profile", "black"] + stages: ["pre-commit"] priority: 20 - repo: https://github.com/psf/black diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000000..117925354f --- /dev/null +++ b/.trivyignore @@ -0,0 +1,85 @@ +# Trivy ignore file for prowlercloud/prowler SDK container image. +# Each entry below documents (a) the affected package and why it ships in the +# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the +# upstream fix status. Entries carry an expiry so they auto-force re-review. +# Entries are scoped per-package so suppressions cannot drift onto unrelated +# packages that may be assigned the same CVE in the future. +# +# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml + +# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks. +# CVE-2026-8376 — perl heap buffer overflow when compiling regex. +# Packages: perl, perl-base, perl-modules-5.36, libperl5.36. +# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be +# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime; +# neither vulnerable code path (Archive::Tar parsing or regex compilation of +# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix +# is available yet. +CVE-2026-42496 pkg:perl exp:2026-07-15 +CVE-2026-42496 pkg:perl-base exp:2026-07-15 +CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15 +CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15 +CVE-2026-8376 pkg:perl exp:2026-07-15 +CVE-2026-8376 pkg:perl-base exp:2026-07-15 +CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15 +CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15 + +# CVE-2025-7458 — SQLite integer overflow. +# Package: libsqlite3-0. +# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The +# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is +# internal and bounded. No Debian bookworm fix is available. +CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15 + +# CVE-2026-43185 — Linux kernel ksmbd signedness bug. +# Package: linux-libc-dev. +# Why ignored: linux-libc-dev ships kernel headers for build-time compilation, +# not a running kernel. Containers execute against the host kernel, so these +# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and +# has not been backported to Debian's 6.1 LTS line. +CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15 + +# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in +# zipOpenNewFileInZip4_64. +# Packages: zlib1g, zlib1g-dev. +# Why ignored: Debian Security Tracker status for bookworm is , with +# the published rationale "contrib/minizip not built and src:zlib not producing +# binary packages" — i.e. the vulnerable symbol is not present in the libz.so +# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in +# zlib 1.3.1, available in Debian trixie (13); migrating the base image would +# clear it fully. +# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853 +CVE-2023-45853 pkg:zlib1g exp:2026-07-15 +CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15 + +# --- API container image (api/Dockerfile) --- +# The entries below are specific to the Prowler API image, which ships +# PowerShell and additional build tooling on top of the same bookworm base. + +# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in +# `xml.parsers.expat` and `xml.etree.ElementTree`. +# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*). +# Why ignored: the API runs under the Python 3.12 interpreter shipped in its +# `.venv`; the system `python3.11` is only present because `python3-dev` is +# pulled in to compile native extensions (xmlsec, lxml) and is never executed +# at runtime. The vulnerable path requires parsing attacker-controlled XML with +# the affected interpreter, which Prowler does not do with the system Python. +# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet. +CVE-2026-7210 pkg:python3.11 exp:2026-07-15 +CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15 +CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15 +CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15 + +# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE). +# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records. +# Package: libunbound8. +# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking +# stack (GnuTLS DANE support); only the shared library ships in the image. Both +# vulnerabilities require operating a live Unbound recursive DNSSEC validator +# that processes attacker-influenced DNS responses. Prowler never starts an +# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet. +CVE-2026-33278 pkg:libunbound8 exp:2026-07-15 +CVE-2026-42960 pkg:libunbound8 exp:2026-07-15 diff --git a/AGENTS.md b/AGENTS.md index 1cf023889c..c9d63a8c27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ Use these skills for detailed patterns on-demand: | `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) | | `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) | | `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) | +| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) | | `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) | | `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) | @@ -67,10 +68,12 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Adding new providers | `prowler-provider` | | Adding privilege escalation detection queries | `prowler-attack-paths-query` | | Adding services to existing providers | `prowler-provider` | +| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` | | After creating/modifying a skill | `skill-sync` | | App Router / Server Actions | `nextjs-16` | | Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` | | Building AI chat features | `ai-sdk-5` | +| Changing button labels or section headings on a tour-covered page | `prowler-tour` | | Committing changes | `prowler-commit` | | Configuring MCP servers in agentic workflows | `gh-aw` | | Create PR that requires changelog entry | `prowler-changelog` | @@ -89,6 +92,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Creating/updating compliance frameworks | `prowler-compliance` | | Debug why a GitHub Actions job is failing | `prowler-ci` | | Debugging gh-aw compilation errors | `gh-aw` | +| Editing a UI file containing data-tour-id attributes | `prowler-tour` | | Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` | | Fixing bug | `tdd` | | Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` | @@ -105,6 +109,8 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST: | Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` | | Refactoring code | `tdd` | | Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` | +| Renaming or removing a data-tour-id attribute value | `prowler-tour` | +| Restructuring routes or layouts covered by a tour | `prowler-tour` | | Review PR requirements: template, title conventions, changelog gate | `prowler-pr` | | Review changelog format and conventions | `prowler-changelog` | | Reviewing JSON:API compliance | `jsonapi` | diff --git a/Dockerfile b/Dockerfile index 0ab4458fad..678cb2b36f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS build +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" @@ -6,7 +6,7 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} -ARG TRIVY_VERSION=0.70.0 +ARG TRIVY_VERSION=0.71.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 diff --git a/Makefile b/Makefile index 8bdb9bf156..e2623a4bf3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,34 @@ .DEFAULT_GOAL:=help +DEV_LOCAL := ./scripts/development/dev-local.sh + +.PHONY: dev dev-setup dev-attach dev-launch dev-stop dev-clean dev-wipe dev-status + +##@ Local Development +dev: ## Start local API, worker, and database logs + $(DEV_LOCAL) all + +dev-setup: ## Bootstrap local dependencies, migrations, and fixtures + $(DEV_LOCAL) setup + +dev-attach: ## Attach to the local tmux development session + $(DEV_LOCAL) attach + +dev-launch: ## Start the local stack on fixed ports and attach + $(DEV_LOCAL) launch + +dev-stop: ## Stop the local tmux session and containers + $(DEV_LOCAL) kill + +dev-clean: ## Remove stopped local development containers + $(DEV_LOCAL) clean + +dev-wipe: ## Stop everything and delete local development data + $(DEV_LOCAL) wipe + +dev-status: ## Show local development container status + $(DEV_LOCAL) status + ##@ Testing test: ## Test with pytest rm -rf .coverage && \ diff --git a/README.md b/README.md index d2808daf84..1886377869 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,9 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically | OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI | | Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | | Okta | 1 | 1 | 0 | 1 | Official | CLI | +| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 0 | 4 | Unofficial | CLI | | Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI | -| StackIT [Contact us](https://prowler.com/contact) | 4 | 1 | 0 | 1 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 0 | 3 | Unofficial | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | > [!Note] diff --git a/api/.env.example b/api/.env.example index 0be5388790..f97d868890 100644 --- a/api/.env.example +++ b/api/.env.example @@ -24,6 +24,9 @@ DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute # Decide whether to allow Django manage database table partitions DJANGO_MANAGE_DB_PARTITIONS=[True|False] DJANGO_CELERY_DEADLOCK_ATTEMPTS=5 +# Optional: bound Celery's prefork pool size. Unset → Celery uses os.cpu_count(). +# Useful on Kubernetes nodes with many CPUs where unbounded prefork balloons memory. +# DJANGO_CELERY_WORKER_CONCURRENCY=4 DJANGO_BROKER_VISIBILITY_TIMEOUT=86400 DJANGO_SENTRY_DSN= diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index dc404079ec..bb19eb81fc 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,25 +2,83 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.31.0] (Prowler UNRELEASED) +## [1.32.0] (Prowler UNRELEASED) + +### 🚀 Added + +- Provider group filters for API endpoints that support cloud provider filtering, including exact and `__in` variants [(#11573)](https://github.com/prowler-cloud/prowler/pull/11573) +- Provider filters for `GET /api/v1/compliance-overviews`, `/metadata`, and `/requirements`, using latest completed scans per matching provider [(#11587)](https://github.com/prowler-cloud/prowler/pull/11587) +- Server-Sent Events (SSE) infrastructure for the API: a base viewset, a tenant-aware channel manager, and channel-name helpers backed by `django-eventstream` over Valkey Pub/Sub and served through the Gunicorn ASGI worker, so feature endpoints can stream events to clients over a single long-lived connection [(#11556)](https://github.com/prowler-cloud/prowler/pull/11556) +- `DJANGO_CELERY_WORKER_CONCURRENCY` to configure Celery workers concurrency. Unset for default behaviour [(#11075)](https://github.com/prowler-cloud/prowler/pull/11075) + +### 🔄 Changed + +- Gunicorn worker timeout raised from the 30s default to 120s, so long-running requests are no longer killed prematurely [(#11631)](https://github.com/prowler-cloud/prowler/pull/11631) +- Sentry now drops ASGI's `RequestAborted` errors from health-check probe disconnects on `/health/live` [(#11632)](https://github.com/prowler-cloud/prowler/pull/11632) +- Gunicorn keep-alive timeout now exceeds the load balancer idle timeout, stopping 502s from reused connections [(#11647)](https://github.com/prowler-cloud/prowler/pull/11647) +- API runs under the Uvicorn worker so keep-alive outlives the load balancer idle timeout, fixing Gunicorn's intermittent 502s [(#11663)](https://github.com/prowler-cloud/prowler/pull/11663) + +### 🐞 Fixed + +- Database connections no longer leak under the ASGI worker, which previously exhausted the read replica's connection slots and caused 500s on read endpoints [(#11640)](https://github.com/prowler-cloud/prowler/pull/11640) + +### 🔐 Security + +- `aiohttp` to 3.14.0 and `idna` to 3.15, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- Container base image to `python:3.12.13-slim-bookworm` and `trivy` to 0.71.0, patching OS and Go module CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- `trivy` binary bumped to 0.71.0 patching embedded `golang.org/x/crypto`, `golang.org/x/net`, and Go `stdlib` CVEs [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592) + +--- + +## [1.31.3] (Prowler v5.30.3) + +### 🔐 Security + +- SAML logins now link to an existing account only when the asserted email domain matches the ACS endpoint and the user is already a member of that domain's tenant, fixing a cross-tenant account takeover [(GHSA-h8m9-jgf8-vwvp)](https://github.com/prowler-cloud/prowler/security/advisories/GHSA-h8m9-jgf8-vwvp) + +--- + +## [1.31.2] (Prowler v5.30.2) + +### 🔄 Changed + +- `scan-compliance-overviews` task now streams the findings aggregation and the requirement-row writes so it runs faster and its peak memory no longer grows with the number of regions and frameworks [(#11591)](https://github.com/prowler-cloud/prowler/pull/11591) + +--- + +## [1.31.1] (Prowler v5.30.1) + +### 🐞 Fixed + +- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) +- Attack Paths: `drop_subgraph` now deletes relationships first and then nodes in batches, using less memory on Neo4j when clearing a dense provider graph [(#11557)](https://github.com/prowler-cloud/prowler/pull/11557) +- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) + +--- + +## [1.31.0] (Prowler v5.30.0) ### 🚀 Added - Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - Label Postgres connections with `application_name=":"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494) +- DISA Okta IDaaS STIG V1R2 compliance framework export support for the Okta provider [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) ### 🔄 Changed - Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- SAML logins no longer wipe a user's roles when the IdP does not send the `userType` attribute; existing roles are kept, and when `userType` names a role that does not exist it is now created with read-only access (visibility over all providers, no management permissions) instead of no permissions at all [(#11520)](https://github.com/prowler-cloud/prowler/pull/11520) ### 🐞 Fixed - Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416) +- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476) +- Compliance catalog now warms in background during startup. `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the API timeout [(#11530)](https://github.com/prowler-cloud/prowler/pull/11530) ### 🔐 Security -- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner in `api/uv.lock` [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) --- diff --git a/api/Dockerfile b/api/Dockerfile index 259492cb08..bb9da0b280 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db AS build +FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build LABEL maintainer="https://github.com/prowler-cloud/api" ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} -ARG TRIVY_VERSION=0.70.0 +ARG TRIVY_VERSION=0.71.0 ENV TRIVY_VERSION=${TRIVY_VERSION} ARG ZIZMOR_VERSION=1.24.1 diff --git a/api/README.md b/api/README.md index ea9661366f..c510479ab2 100644 --- a/api/README.md +++ b/api/README.md @@ -196,6 +196,42 @@ python -m celery -A config.celery worker -l info -E The Celery worker does not detect and reload changes in the code, so you need to restart it manually when you make changes. +### Makefile-Assisted Local Deployment + +This method is an additional local development workflow. It does not replace the manual local deployment or the Docker deployment described in this guide. + +PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. Additionally, this workflow creates a `tmux` session with panes for the API, worker, and PostgreSQL logs. + +Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed. + +This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported. + +From the repository root, run: + +```console +make dev +``` + +The API will be available at: + +```console +http://localhost:8080/api/v1 +``` + +Use these commands to manage the local stack: + +```console +make dev-setup # Bootstrap dependencies, migrations, and fixtures +make dev-attach # Attach to the tmux session +make dev-launch # Start the stack on fixed ports and attach +make dev-stop # Stop the tmux session and containers +make dev-clean # Remove stopped development containers +make dev-wipe # Stop everything and delete local development data +make dev-status # Show development container status +``` + +This workflow does not start the UI. Start it separately from the `ui/` directory when needed. + ### Docker deployment This method requires `docker` and `docker compose`. diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index 9b2964b479..e077a3bfd6 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -21,13 +21,19 @@ apply_fixtures() { } start_dev_server() { - echo "Starting the development server..." - exec uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}" + echo "Starting the development server (Gunicorn ASGI, debug + reload)..." + # Same server/worker as prod (config.asgi via the native `asgi` worker), so + # SSE streams run on the event loop exactly as they do in production. DEBUG is + # on so guniconf's `reload = DEBUG` hot-reloads edited code (and flips + # `preload_app` off so reload actually takes). + export DJANGO_DEBUG="${DJANGO_DEBUG:-True}" + export DJANGO_BIND_ADDRESS="${DJANGO_BIND_ADDRESS:-0.0.0.0}" + exec uv run gunicorn -c config/guniconf.py config.asgi:application } start_prod_server() { echo "Starting the Gunicorn server..." - exec uv run gunicorn -c config/guniconf.py config.wsgi:application + exec uv run gunicorn -c config/guniconf.py config.asgi:application } resolve_worker_hostname() { diff --git a/api/docs/orphan-task-recovery.md b/api/docs/orphan-task-recovery.md index a47b4f36a9..af4eefc1ac 100644 --- a/api/docs/orphan-task-recovery.md +++ b/api/docs/orphan-task-recovery.md @@ -65,6 +65,7 @@ All settings have safe defaults; override via environment variables. | Env var | Default | Purpose | | --- | --- | --- | | `DJANGO_CELERY_WORKER_PREFETCH_MULTIPLIER` | `1` | Tasks reserved per worker process. | +| `DJANGO_CELERY_WORKER_CONCURRENCY` | unset | Optional Celery prefork pool size. When unset, Celery uses its CPU-based default. Set this on worker containers to bound idle memory on hosts with many CPUs. | | `DJANGO_CELERY_WORKER_SOFT_SHUTDOWN_TIMEOUT` | `60` | Seconds the worker drains/re-queues on `SIGTERM` before force-kill. | | `DJANGO_CELERY_TASK_TIME_LIMIT` | `21600` (6h) | Hard limit for most tasks; connection checks are capped at 120s. | | `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. | diff --git a/api/pyproject.toml b/api/pyproject.toml index 5e18ffac20..f678b908fc 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -41,7 +41,9 @@ dependencies = [ "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", "defusedxml==0.7.1", - "gunicorn==23.0.0", + "django-eventstream==5.3.3", + "gunicorn==26.0.0", + "uvloop==0.22.1", "lxml==6.1.0", "prowler @ git+https://github.com/prowler-cloud/prowler.git@master", "psycopg2-binary==2.9.9", @@ -60,7 +62,8 @@ dependencies = [ "gevent (==25.9.1)", "werkzeug (==3.1.7)", "sqlparse (==0.5.5)", - "fonttools (==4.62.1)" + "fonttools (==4.62.1)", + "uvicorn-worker (==0.4.0)", ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" @@ -68,7 +71,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.31.0" +version = "1.32.0" [tool.uv] # Transitive pins matching master to avoid silent drift; bump deliberately. @@ -79,7 +82,7 @@ constraint-dependencies = [ "aiobotocore==2.25.1", "aiofiles==24.1.0", "aiohappyeyeballs==2.6.1", - "aiohttp==3.13.5", + "aiohttp==3.14.0", "aioitertools==0.13.0", "aiosignal==1.4.0", "alibabacloud-actiontrail20200706==2.4.1", @@ -124,9 +127,8 @@ constraint-dependencies = [ "astroid==3.2.4", "async-timeout==5.0.1", "attrs==25.4.0", - "authlib==1.6.9", + "authlib==1.6.12", "autopep8==2.3.2", - "awsipranges==0.3.3", "azure-cli-core==2.83.0", "azure-cli-telemetry==1.1.0", "azure-common==1.1.28", @@ -209,6 +211,7 @@ constraint-dependencies = [ "django-celery-results==2.6.0", "django-cors-headers==4.4.0", "django-environ==0.11.2", + "django-eventstream==5.3.3", "django-filter==24.3", "django-guid==3.5.0", "django-postgres-extra==2.0.9", @@ -253,7 +256,7 @@ constraint-dependencies = [ "grpc-google-iam-v1==0.14.3", "grpcio==1.76.0", "grpcio-status==1.76.0", - "gunicorn==23.0.0", + "gunicorn==26.0.0", "h11==0.16.0", "h2==4.3.0", "hpack==4.1.0", @@ -262,8 +265,8 @@ constraint-dependencies = [ "httpx==0.28.1", "humanfriendly==10.0", "hyperframe==6.1.0", - "iamdata==0.1.202602021", - "idna==3.11", + "iamdata==0.1.202605131", + "idna==3.15", "importlib-metadata==8.7.1", "inflection==0.5.1", "iniconfig==2.3.0", @@ -315,7 +318,7 @@ constraint-dependencies = [ "neo4j==6.1.0", "nest-asyncio==1.6.0", "nltk==3.9.4", - "numpy==2.0.2", + "numpy==2.2.6", "oauthlib==3.3.1", "oci==2.169.0", "openai==1.109.1", @@ -344,7 +347,7 @@ constraint-dependencies = [ "psutil==7.2.2", "psycopg2-binary==2.9.9", "py-deviceid==0.1.1", - "py-iam-expand==0.1.0", + "py-iam-expand==0.3.0", "py-ocsf-models==0.8.1", "pyasn1==0.6.3", "pyasn1-modules==0.4.2", @@ -420,6 +423,8 @@ constraint-dependencies = [ "uritemplate==4.2.0", "urllib3==2.7.0", "uuid6==2024.7.10", + "uvicorn==0.49.0", + "uvloop==0.22.1", "vine==5.1.0", "vulture==2.14", "wcwidth==0.5.3", diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index e09dc972b4..1d0d2ace00 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -3,7 +3,14 @@ from django.db import transaction from api.db_router import MainRouter from api.db_utils import rls_transaction -from api.models import Membership, Role, Tenant, User, UserRoleRelationship +from api.models import ( + Membership, + Role, + SAMLConfiguration, + Tenant, + User, + UserRoleRelationship, +) class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): @@ -18,7 +25,42 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): # Link existing accounts with the same email address email = sociallogin.account.extra_data.get("email") if sociallogin.provider.id == "saml": + # For SAML, the asserted NameID email cannot be trusted on its own: + # any tenant can claim any email domain in its SAML configuration. To + # prevent cross-tenant account takeover (GHSA-h8m9-jgf8-vwvp), only link + # the incoming SAML session to an existing account when (1) the email + # domain matches the tenant whose ACS endpoint is being used and (2) the + # existing user is already a member of that tenant. email = sociallogin.user.email + if not email: + return + + domain = email.rsplit("@", 1)[-1].lower() + resolver_match = getattr(request, "resolver_match", None) + organization_slug = ( + (resolver_match.kwargs or {}).get("organization_slug", "") + if resolver_match + else "" + ).lower() + # The ACS endpoint is scoped per email domain; reject mismatches so an + # attacker cannot replay an assertion through another tenant's endpoint. + if organization_slug != domain: + return + + try: + saml_config = SAMLConfiguration.objects.using(MainRouter.admin_db).get( + email_domain=domain + ) + except SAMLConfiguration.DoesNotExist: + return + + existing_user = self.get_user_by_email(email) + if existing_user and existing_user.is_member_of_tenant( + str(saml_config.tenant_id) + ): + sociallogin.connect(request, existing_user) + return + if email: existing_user = self.get_user_by_email(email) if existing_user: diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index d5cc1698a7..0e6cc083dc 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -175,7 +175,8 @@ def drop_subgraph(database: str, provider_id: str) -> int: """ Delete all nodes for a provider from the tenant database. - Uses batched deletion to avoid memory issues with large graphs. + Deletes relationships then nodes in batches (not `DETACH DELETE`) so a dense + provider's graph cannot exceed Neo4j's transaction memory limit. Silently returns 0 if the database doesn't exist. """ provider_label = get_provider_label(provider_id) @@ -183,13 +184,28 @@ def drop_subgraph(database: str, provider_id: str) -> int: try: with get_session(database) as session: + # Phase 1: delete relationships incident to provider nodes in batches. + deleted_count = 1 + while deleted_count > 0: + result = session.run( + f""" + MATCH (:`{provider_label}`)-[r]-() + WITH DISTINCT r LIMIT $batch_size + DELETE r + RETURN COUNT(r) AS deleted_rels_count + """, + {"batch_size": BATCH_SIZE}, + ) + deleted_count = result.single().get("deleted_rels_count", 0) + + # Phase 2: delete the now relationship-free nodes in batches. deleted_count = 1 while deleted_count > 0: result = session.run( f""" MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) WITH n LIMIT $batch_size - DETACH DELETE n + DELETE n RETURN COUNT(n) AS deleted_nodes_count """, {"batch_size": BATCH_SIZE}, diff --git a/api/src/backend/api/authentication.py b/api/src/backend/api/authentication.py index 499e290bb7..04740ac219 100644 --- a/api/src/backend/api/authentication.py +++ b/api/src/backend/api/authentication.py @@ -93,3 +93,30 @@ class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication): # Default fallback return self.jwt_auth.authenticate(request) + + +class SSEAuthentication(CombinedJWTOrAPIKeyAuthentication): + """JWT/API-Key auth that also accepts `?access_token=`. + + Browser `EventSource` is the only widely available SSE client API + and it cannot set the `Authorization` header (its constructor takes + only a URL and `withCredentials`). To keep browser SSE clients on + the same auth stack as the rest of the API, SSE endpoints additionally + accept a JWT via the `?access_token=` query parameter — the + standard parameter name defined in RFC 6750 Section 2.3 for bearer tokens. + """ + + def authenticate(self, request: Request): + auth_header = request.headers.get("Authorization", "") + if auth_header: + return super().authenticate(request) + + raw_token = request.query_params.get("access_token") + if not raw_token: + # No header and no query token — let the default path raise + # the canonical AuthenticationFailed via the parent class. + return super().authenticate(request) + + validated_token = self.jwt_auth.get_validated_token(raw_token) + user = self.jwt_auth.get_user(validated_token) + return user, validated_token diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 678aff8d57..77c45cbffd 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,3 +1,5 @@ +import logging +import threading from collections.abc import Iterable, Mapping from api.models import Provider @@ -6,8 +8,19 @@ from prowler.lib.check.compliance_models import ( ) from prowler.lib.check.models import CheckMetadata +logger = logging.getLogger(__name__) + AVAILABLE_COMPLIANCE_FRAMEWORKS = {} +# Per-process readiness flags for the background compliance warm-up. +# `STARTED` is set as soon as warming begins (only happens under Gunicorn via +# the post_fork hook); `WARMED` is set when it finishes. The attributes +# endpoint checks both: it returns 503 only while warming is in progress. +# Under `runserver` warming never runs, so `STARTED` stays clear and the +# endpoint keeps lazy-loading as before. +COMPLIANCE_WARMING_STARTED = threading.Event() +COMPLIANCE_WARMED = threading.Event() + class LazyComplianceTemplate(Mapping): """Lazy-load compliance templates per provider on first access.""" @@ -99,14 +112,14 @@ def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[s """List compliance framework identifiers available for `provider_type`. Includes both per-provider frameworks and universal top-level frameworks - (e.g. ``dora``, ``csa_ccm_4.0``). + (e.g. ``dora_2022_2554``, ``csa_ccm_4.0``). Args: provider_type (Provider.ProviderChoices): The cloud provider type (e.g., "aws", "azure", "gcp", "m365"). Returns: - list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora"). + list[str]: Framework identifiers (e.g., "cis_1.4_aws", "dora_2022_2554"). """ global AVAILABLE_COMPLIANCE_FRAMEWORKS if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS: @@ -174,6 +187,56 @@ def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None: PROWLER_CHECKS._cache[provider_type] = checks +def warm_compliance_caches( + provider_types: Iterable[str] | None = None, +) -> list[str]: + """ + Eagerly populate the per-process compliance caches at server startup. + + Moves the cold-cache catalog load off the request thread so the first + request does not trip the Gunicorn worker timeout. Reads only on-disk + metadata (no database access). Each provider is warmed in isolation; + failures are logged and fall back to lazy loading. + + Args: + provider_types (Iterable[str] | None): Subset to warm. Defaults to all. + + Returns: + list[str]: Provider types that could not be warmed. + """ + if provider_types is None: + provider_types = Provider.ProviderChoices.values + provider_types = list(provider_types) + + COMPLIANCE_WARMING_STARTED.set() + logger.info("Compliance cache warm-up started for providers: %s", provider_types) + + failed = [] + for provider_type in provider_types: + try: + get_compliance_frameworks(provider_type) + _ensure_provider_loaded(provider_type) + # Prowler check loading may sys.exit (SystemExit, not Exception). + except (Exception, SystemExit): + logger.warning( + "Failed to warm compliance caches for provider '%s'; " + "loading lazily on first request", + provider_type, + exc_info=True, + ) + failed.append(provider_type) + + # Mark as warmed even when some providers failed: a failed provider falls + # back to a single-provider lazy load, which stays under the worker timeout. + COMPLIANCE_WARMED.set() + logger.info( + "Compliance cache warm-up finished (providers warmed: %d, failed: %s)", + len(provider_types) - len(failed), + failed, + ) + return failed + + def load_prowler_checks( prowler_compliance, provider_types: Iterable[str] | None = None ): diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 78f8c64c7d..4f6f26c2ea 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -187,6 +187,32 @@ class UpstreamServiceUnavailableError(APIException): ) +class ComplianceWarmingError(APIException): + """Compliance catalog is still warming (503 Service Unavailable). + + Returned by the compliance attributes endpoint while the per-process + catalog warm-up is in progress, so the request thread never triggers the + slow cold load that would trip the Gunicorn worker timeout. + """ + + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = ( + "Compliance data is still loading. Please try again in a few seconds." + ) + default_code = "compliance_warming" + + def __init__(self, detail=None): + super().__init__( + detail=[ + { + "detail": detail or self.default_detail, + "status": str(self.status_code), + "code": self.default_code, + } + ] + ) + + class UpstreamInternalError(APIException): """Unexpected error communicating with provider (500 Internal Server Error). diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index c787d714b1..195982011d 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -102,7 +102,7 @@ class BaseProviderFilter(FilterSet): """ Abstract base filter for models with direct FK to Provider. - Provides standard provider_id and provider_type filters. + Provides standard provider_id, provider_type, and provider_groups filters. Subclasses must define Meta.model. """ @@ -116,6 +116,16 @@ class BaseProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: abstract = True @@ -126,7 +136,7 @@ class BaseScanProviderFilter(FilterSet): """ Abstract base filter for models with FK to Scan (and Scan has FK to Provider). - Provides standard provider_id and provider_type filters via scan relationship. + Provides standard provider_id, provider_type, and provider_groups filters via scan relationship. Subclasses must define Meta.model. """ @@ -140,6 +150,16 @@ class BaseScanProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: abstract = True @@ -160,6 +180,16 @@ class CommonFindingFilters(FilterSet): provider_type__in = ChoiceInFilter( choices=Provider.ProviderChoices.choices, field_name="scan__provider__provider" ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) provider_uid = CharFilter(field_name="scan__provider__uid", lookup_expr="exact") provider_uid__in = CharInFilter(field_name="scan__provider__uid", lookup_expr="in") provider_uid__icontains = CharFilter( @@ -370,6 +400,12 @@ class ProviderFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider_groups__id", lookup_expr="exact", distinct=True + ) + provider_groups__in = UUIDInFilter( + field_name="provider_groups__id", lookup_expr="in", distinct=True + ) class Meta: model = Provider @@ -395,6 +431,16 @@ class ProviderRelationshipFilterSet(FilterSet): provider_type__in = ChoiceInFilter( choices=Provider.ProviderChoices.choices, field_name="provider__provider" ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) provider_uid = CharFilter(field_name="provider__uid", lookup_expr="exact") provider_uid__in = CharInFilter(field_name="provider__uid", lookup_expr="in") provider_uid__icontains = CharFilter( @@ -1001,6 +1047,16 @@ class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): field_name="provider__provider", choices=Provider.ProviderChoices.choices ) provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in") + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: model = FindingGroupDailySummary @@ -1101,6 +1157,16 @@ class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet): field_name="provider__provider", choices=Provider.ProviderChoices.choices ) provider_type__in = CharInFilter(field_name="provider__provider", lookup_expr="in") + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) class Meta: model = FindingGroupDailySummary @@ -1280,12 +1346,19 @@ class RoleFilter(FilterSet): } -class ComplianceOverviewFilter(FilterSet): +class ComplianceOverviewFilter(BaseScanProviderFilter): + """ + Keep provider filters in the schema while runtime filtering resolves scans first. + + Compliance overview provider filters are applied to the latest completed scans + in the viewset, then this filterset handles the remaining compliance fields. + """ + inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") - scan_id = UUIDFilter(field_name="scan_id", required=True) + scan_id = UUIDFilter(field_name="scan_id") region = CharFilter(field_name="region") - class Meta: + class Meta(BaseScanProviderFilter.Meta): model = ComplianceRequirementOverview fields = { "inserted_at": ["date", "gte", "lte"], @@ -1306,6 +1379,16 @@ class ScanSummaryFilter(FilterSet): provider_type__in = ChoiceInFilter( field_name="scan__provider__provider", choices=Provider.ProviderChoices.choices ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) region = CharFilter(field_name="region") class Meta: @@ -1329,6 +1412,16 @@ class DailySeveritySummaryFilter(FilterSet): provider_type__in = ChoiceInFilter( field_name="provider__provider", choices=Provider.ProviderChoices.choices ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) date_from = DateFilter(method="filter_noop") date_to = DateFilter(method="filter_noop") @@ -1585,6 +1678,16 @@ class ThreatScoreSnapshotFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) compliance_id = CharFilter(field_name="compliance_id", lookup_expr="exact") compliance_id__in = CharInFilter(field_name="compliance_id", lookup_expr="in") @@ -1628,6 +1731,16 @@ class ResourceGroupOverviewFilter(FilterSet): choices=Provider.ProviderChoices.choices, lookup_expr="in", ) + provider_groups = UUIDFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="exact", + distinct=True, + ) + provider_groups__in = UUIDInFilter( + field_name="scan__provider__provider_groups__id", + lookup_expr="in", + distinct=True, + ) resource_group = CharFilter(field_name="resource_group", lookup_expr="exact") resource_group__in = CharInFilter(field_name="resource_group", lookup_expr="in") diff --git a/api/src/backend/api/middleware.py b/api/src/backend/api/middleware.py index 63f2fc630b..2b0a2340c4 100644 --- a/api/src/backend/api/middleware.py +++ b/api/src/backend/api/middleware.py @@ -1,9 +1,35 @@ import logging import time +from django.core.handlers.asgi import ASGIRequest +from django.db import connections + from config.custom_logging import BackendLogger +class CloseDBConnectionsMiddleware: + """ + Close request-scoped DB connections at the end of each ASGI request. + + Under the ASGI worker, connections opened by sync views are not released + by Django's normal request-boundary cleanup, so they accumulate idle until + Postgres runs out of slots. Only ASGI requests are handled; the sync WSGI + test client manages its own connections and must be left alone. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + return self.get_response(request) + finally: + if isinstance(request, ASGIRequest): + for conn in connections.all(initialized_only=True): + if not conn.in_atomic_block: + conn.close_if_unusable_or_obsolete() + + def extract_auth_info(request) -> dict: if getattr(request, "auth", None) is not None: tenant_id = request.auth.get("tenant_id", "N/A") diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index d2a30ca897..18566c5c71 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.31.0 + version: 1.32.0 description: |- Prowler API specification. diff --git a/api/src/backend/api/sse/__init__.py b/api/src/backend/api/sse/__init__.py new file mode 100644 index 0000000000..244bd3c9ad --- /dev/null +++ b/api/src/backend/api/sse/__init__.py @@ -0,0 +1,13 @@ +"""Platform Server-Sent Events (SSE) infrastructure. + +Wires `django-eventstream` into the API: a base viewset features +subclass to expose an SSE endpoint +(:class:`api.sse.base_views.BaseSSEViewSet`), the channel manager that +enforces the tenant gate (:class:`api.sse.channelmanager.SSEChannelManager`), +and the channel-name helpers (:func:`api.sse.utils.make_channel_name`). +""" + +from api.sse.utils import make_channel_name +from api.sse.base_views import BaseSSEViewSet + +__all__ = ["BaseSSEViewSet", "make_channel_name"] diff --git a/api/src/backend/api/sse/base_views.py b/api/src/backend/api/sse/base_views.py new file mode 100644 index 0000000000..c4a24540ee --- /dev/null +++ b/api/src/backend/api/sse/base_views.py @@ -0,0 +1,46 @@ +"""Base view class for SSE endpoints.""" + +from api.authentication import SSEAuthentication +from api.base_views import BaseRLSViewSet +from django_eventstream.renderers import SSEEventRenderer +from django_eventstream.views import events + + +class BaseSSEViewSet(BaseRLSViewSet): + """Base class for platform SSE endpoints. + + Subclasses override method `get_channels` to declare the channel + names the connection should subscribe to — the same way a regular + DRF viewset overrides method `get_queryset`. The channel manager + reads the result from `request.sse_channels`; there is no other + coupling between platform and feature. + """ + + authentication_classes = [SSEAuthentication] + # Pin the SSE renderer so content negotiation accepts the browser's + # `Accept: text/event-stream`. + renderer_classes = [SSEEventRenderer] + + def get_channels(self) -> set[str]: + """Return the channels this connection subscribes to. + + Implementations MUST raise the relevant DRF exceptions + (`NotAuthenticated`, `PermissionDenied`, `NotFound`) when + authorization fails. Returning an empty set would surface as + django-eventstream's "No channels specified" which masks the + real cause. + """ + raise NotImplementedError + + def get_queryset(self): + # Most SSE viewsets only need `get_channels` and never call + # `get_queryset` (the SSE list path bypasses serialization + # entirely). Subclasses that perform their own queryset lookup + # inside `get_channels` should override; the default raises + # the same error a missing override on a ModelViewSet would. + raise NotImplementedError + + def list(self, request, *_args, **kwargs): + """Resolve channels under the regular DRF stack and stream.""" + request.sse_channels = self.get_channels() + return events(request, **kwargs) diff --git a/api/src/backend/api/sse/channelmanager.py b/api/src/backend/api/sse/channelmanager.py new file mode 100644 index 0000000000..72b2c18367 --- /dev/null +++ b/api/src/backend/api/sse/channelmanager.py @@ -0,0 +1,75 @@ +"""Channel manager that wires `django-eventstream` to platform SSE views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from django_eventstream.channelmanager import DefaultChannelManager +from rest_framework.request import Request + +from api.sse.utils import tenant_id_from_channel + +if TYPE_CHECKING: + from api.models import User + + +class SSEChannelManager(DefaultChannelManager): + """Connect `django-eventstream` to the platform's SSE viewsets.""" + + def get_channels_for_request(self, request: Request, view_kwargs: dict) -> set[str]: # noqa: vulture + """Return the request's channels scoped to the active JWT tenant. + + Args: + request: The authenticated DRF request, carrying `tenant_id` + (set by `BaseRLSViewSet`) and `sse_channels` (set by + `BaseSSEViewSet.list`). + view_kwargs: URL keyword arguments from django-eventstream; + unused because channels are resolved on the request. + + Returns: + The subset of `request.sse_channels` whose embedded tenant + matches the active request tenant. + """ + try: + request_tenant_id = UUID(str(getattr(request, "tenant_id", None))) + except (TypeError, ValueError): + return set() + return { + channel + for channel in getattr(request, "sse_channels", set()) + if tenant_id_from_channel(channel) == request_tenant_id + } + + def can_read_channel(self, user: "User | None", channel: str) -> bool: + """Re-verify tenant membership once the stream is established. + + Args: + user: The connection's authenticated `User`, or `None` for an + anonymous connection — django-eventstream passes `None` + rather than an `AnonymousUser`. + channel: The channel name being read, in the canonical + `::` format. + + Returns: + `True` only when `user` is authenticated and a member of the + tenant embedded in `channel`; `False` otherwise, including for + anonymous connections and malformed channel names. + """ + if user is None or not user.is_authenticated: + return False + tenant_id = tenant_id_from_channel(channel) + if tenant_id is None: + return False + return user.is_member_of_tenant(tenant_id) + + def is_channel_reliable(self, channel: str) -> bool: + """Report whether the channel keeps a server-side replay buffer. + + Args: + channel: The channel name being queried. + + Returns: + `False`, unconditionally. Replay storage is not configured + """ + return False diff --git a/api/src/backend/api/sse/utils.py b/api/src/backend/api/sse/utils.py new file mode 100644 index 0000000000..a30ed26311 --- /dev/null +++ b/api/src/backend/api/sse/utils.py @@ -0,0 +1,51 @@ +"""Channel-name convention shared by SSE publishers, consumers, and the +channel manager. The format is `::`. +""" + +from __future__ import annotations + +import uuid + +CHANNEL_SEPARATOR = ":" + + +def make_channel_name( + prefix: str, + tenant_id: str | uuid.UUID, + resource_id: str | uuid.UUID, +) -> str: + """Build the canonical channel name for a resource. + + Args: + prefix: Feature-owned prefix (e.g. `"lighthouse-session"`). + tenant_id: Tenant the resource belongs to. + resource_id: Resource identifier within the tenant. + + Raises: + ValueError: If any segment contains `CHANNEL_SEPARATOR`, which + would break the `::` contract + and let a crafted name smuggle extra segments past the parser. + """ + segments = (str(prefix), str(tenant_id), str(resource_id)) + if any(CHANNEL_SEPARATOR in segment for segment in segments): + raise ValueError( + f"Channel segments must not contain '{CHANNEL_SEPARATOR}': {segments!r}" + ) + return CHANNEL_SEPARATOR.join(segments) + + +def tenant_id_from_channel(channel: str) -> uuid.UUID | None: + """Return the tenant UUID embedded in *channel*, or `None` if + *channel* does not follow the platform convention. + + A `None` result MUST be treated by callers as "not authorized" or + a malformed channel cannot be safely read. + """ + segments = channel.split(CHANNEL_SEPARATOR) + if len(segments) != 3: + # Reject non-canonical names + return None + try: + return uuid.UUID(segments[1]) + except ValueError: + return None diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 22b44b3506..182e4ddc25 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -5,9 +6,48 @@ from allauth.socialaccount.models import SocialLogin from django.contrib.auth import get_user_model from api.adapters import ProwlerSocialAccountAdapter +from api.db_router import MainRouter +from api.models import SAMLConfiguration User = get_user_model() +# Minimal, well-formed IdP metadata accepted by SAMLConfiguration._parse_metadata. +VALID_METADATA = """ + + + + + + FAKECERTDATA + + + + + + +""" + + +def _saml_request(rf, organization_slug): + """Build an ACS request whose resolver_match carries the organization slug, + mirroring how Django populates it after routing the SAML ACS URL.""" + request = rf.post(f"/api/v1/accounts/saml/{organization_slug}/acs/finish/") + request.resolver_match = SimpleNamespace( + kwargs={"organization_slug": organization_slug} + ) + return request + + +def _saml_sociallogin(user): + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = MagicMock() + sociallogin.provider = MagicMock() + sociallogin.provider.id = "saml" + sociallogin.account.extra_data = {} + sociallogin.user = user + sociallogin.connect = MagicMock() + return sociallogin + @pytest.mark.django_db class TestProwlerSocialAccountAdapter: @@ -20,26 +60,99 @@ class TestProwlerSocialAccountAdapter: adapter = ProwlerSocialAccountAdapter() assert adapter.get_user_by_email("notfound@example.com") is None - def test_pre_social_login_links_existing_user(self, create_test_user, rf): + def test_pre_social_login_links_member_of_saml_tenant( + self, create_test_user, tenants_fixture, rf + ): + """A SAML login links to an existing account only when that user is + already a member of the tenant that owns the asserted email domain.""" adapter = ProwlerSocialAccountAdapter() + # create_test_user (dev@prowler.com) is a member of tenant1. + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) - sociallogin = MagicMock(spec=SocialLogin) - sociallogin.account = MagicMock() - sociallogin.provider = MagicMock() - sociallogin.provider.id = "saml" - sociallogin.account.extra_data = {} - sociallogin.user = create_test_user - sociallogin.connect = MagicMock() - - adapter.pre_social_login(rf.get("/"), sociallogin) + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) call_args = sociallogin.connect.call_args assert call_args is not None - - called_request, called_user = call_args[0] - assert called_request.path == "/" + _, called_user = call_args[0] assert called_user.email == create_test_user.email + def test_pre_social_login_blocks_cross_tenant_takeover( + self, create_test_user, tenants_fixture, rf + ): + """GHSA-h8m9-jgf8-vwvp: an attacker tenant that claims the victim's + email domain must NOT be able to link to the victim's account, because + the victim is not a member of the attacker's tenant.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + # tenant3 is the attacker tenant; create_test_user is NOT a member of it. + attacker_tenant = tenants_fixture[2] + assert not create_test_user.is_member_of_tenant(str(attacker_tenant.id)) + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=attacker_tenant, + ) + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_domain_slug_mismatch( + self, create_test_user, tenants_fixture, rf + ): + """The asserted email domain must match the ACS endpoint's slug, so an + assertion cannot be replayed through a different tenant's endpoint.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) + + sociallogin = _saml_sociallogin(create_test_user) + # Slug points at a different domain than the asserted email. + adapter.pre_social_login(_saml_request(rf, "attacker.com"), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_when_no_saml_config( + self, create_test_user, tenants_fixture, rf + ): + """No SAML configuration for the domain means nothing to link against.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(_saml_request(rf, domain), sociallogin) + + sociallogin.connect.assert_not_called() + + def test_pre_social_login_blocks_without_resolver_match( + self, create_test_user, tenants_fixture, rf + ): + """Fail closed: if the request has no resolver_match we cannot bind the + assertion to a tenant, so no linking happens.""" + adapter = ProwlerSocialAccountAdapter() + domain = create_test_user.email.rsplit("@", 1)[-1] + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=domain, + metadata_xml=VALID_METADATA, + tenant=tenants_fixture[0], + ) + + sociallogin = _saml_sociallogin(create_test_user) + adapter.pre_social_login(rf.post("/"), sociallogin) + + sociallogin.connect.assert_not_called() + def test_pre_social_login_no_link_if_email_missing(self, rf): adapter = ProwlerSocialAccountAdapter() @@ -47,14 +160,35 @@ class TestProwlerSocialAccountAdapter: sociallogin.account = MagicMock() sociallogin.provider = MagicMock() sociallogin.user = MagicMock() + sociallogin.user.email = "" sociallogin.provider.id = "saml" sociallogin.account.extra_data = {} sociallogin.connect = MagicMock() - adapter.pre_social_login(rf.get("/"), sociallogin) + adapter.pre_social_login(_saml_request(rf, "prowler.com"), sociallogin) sociallogin.connect.assert_not_called() + def test_pre_social_login_non_saml_links_by_email(self, create_test_user, rf): + """Non-SAML providers (e.g. Google/GitHub) still link to an existing + local account by email; the tenant binding only applies to SAML.""" + adapter = ProwlerSocialAccountAdapter() + + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = MagicMock() + sociallogin.provider = MagicMock() + sociallogin.provider.id = "google" + sociallogin.account.extra_data = {"email": create_test_user.email} + sociallogin.user = create_test_user + sociallogin.connect = MagicMock() + + adapter.pre_social_login(rf.get("/"), sociallogin) + + call_args = sociallogin.connect.call_args + assert call_args is not None + _, called_user = call_args[0] + assert called_user.email == create_test_user.email + def test_save_user_saml_sets_session_flag(self, rf): adapter = ProwlerSocialAccountAdapter() request = rf.get("/") 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 3a29a1007d..7ca8a4accb 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -542,3 +542,84 @@ class TestHasProviderData: ): with pytest.raises(db_module.GraphDatabaseQueryException): db_module.has_provider_data("db-tenant-abc", "provider-123") + + +class TestDropSubgraph: + """Test drop_subgraph two-phase batched deletion of a provider's graph.""" + + @staticmethod + def _result(count): + result = MagicMock() + result.single.return_value.get.return_value = count + return result + + @staticmethod + def _session_ctx(session): + ctx = MagicMock() + ctx.__enter__.return_value = session + ctx.__exit__.return_value = False + return ctx + + def test_deletes_relationships_then_nodes_in_batches(self): + session = MagicMock() + # Phase 1 (relationships): one full batch then empty. + # Phase 2 (nodes): one full batch then empty. + session.run.side_effect = [ + self._result(1000), + self._result(0), + self._result(1000), + self._result(0), + ] + + with patch( + "api.attack_paths.database.get_session", + return_value=self._session_ctx(session), + ): + deleted = db_module.drop_subgraph("db-tenant-abc", "provider-123") + + # Only phase-2 node counts contribute to the return value. + assert deleted == 1000 + assert session.run.call_count == 4 + + queries = [call.args[0] for call in session.run.call_args_list] + + # Regression guard: the memory blow-up was caused by DETACH DELETE. + assert all("DETACH DELETE" not in query for query in queries) + + rel_queries = [query for query in queries if "DELETE r" in query] + node_queries = [query for query in queries if "DELETE n" in query] + assert rel_queries and node_queries + # DISTINCT avoids double-counting relationships matched from both ends. + assert all("DISTINCT r" in query for query in rel_queries) + + # Relationships must be fully drained before nodes are deleted. + first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q) + last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q) + assert last_rel < first_node + + def test_returns_zero_when_database_not_found(self): + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Database does not exist", + code="Neo.ClientError.Database.DatabaseNotFound", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + assert db_module.drop_subgraph("db-tenant-gone", "provider-123") == 0 + + def test_raises_on_other_errors(self): + session_ctx = MagicMock() + session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException( + message="Connection refused", + code="Neo.TransientError.General.UnknownError", + ) + + with patch( + "api.attack_paths.database.get_session", + return_value=session_ctx, + ): + with pytest.raises(db_module.GraphDatabaseQueryException): + db_module.drop_subgraph("db-tenant-abc", "provider-123") diff --git a/api/src/backend/api/tests/test_authentication.py b/api/src/backend/api/tests/test_authentication.py index 6745c36e91..505d7c9320 100644 --- a/api/src/backend/api/tests/test_authentication.py +++ b/api/src/backend/api/tests/test_authentication.py @@ -1,13 +1,13 @@ import time from datetime import datetime, timedelta, timezone -from unittest.mock import patch +from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest from django.test import RequestFactory from rest_framework.exceptions import AuthenticationFailed -from api.authentication import TenantAPIKeyAuthentication +from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication from api.db_router import MainRouter from api.models import TenantAPIKey @@ -382,3 +382,62 @@ class TestTenantAPIKeyAuthentication: auth_backend.authenticate(request) assert str(exc_info.value.detail) == "API Key has already expired." + + +class TestSSEAuthentication: + """`SSEAuthentication` adds an `?access_token=` fallback for + browser `EventSource` clients while keeping the standard + `Authorization` header as the authoritative source.""" + + def test_header_present_delegates_to_super(self): + request = MagicMock() + request.headers = {"Authorization": "Bearer header-token"} + with patch.object( + SSEAuthentication.__bases__[0], "authenticate", return_value=("user", "tok") + ) as super_auth: + result = SSEAuthentication().authenticate(request) + super_auth.assert_called_once_with(request) + assert result == ("user", "tok") + + def test_no_header_no_query_token_delegates_to_super(self): + request = MagicMock() + request.headers = {} + request.query_params = {} + with patch.object( + SSEAuthentication.__bases__[0], "authenticate", return_value=None + ) as super_auth: + result = SSEAuthentication().authenticate(request) + super_auth.assert_called_once_with(request) + assert result is None + + def test_query_token_used_only_as_fallback(self): + request = MagicMock() + request.headers = {} + request.query_params = {"access_token": "query-jwt"} + + jwt_instance = MagicMock() + jwt_instance.get_validated_token.return_value = "validated" + jwt_instance.get_user.return_value = "query-user" + + with patch.object(SSEAuthentication, "jwt_auth", jwt_instance): + user, token = SSEAuthentication().authenticate(request) + + jwt_instance.get_validated_token.assert_called_once_with("query-jwt") + assert user == "query-user" + assert token == "validated" + + def test_query_token_invalid_raises_authentication_failed(self): + request = MagicMock() + request.headers = {} + request.query_params = {"access_token": "bad-token"} + + jwt_instance = MagicMock() + jwt_instance.get_validated_token.side_effect = AuthenticationFailed( + "Invalid token" + ) + + with patch.object(SSEAuthentication, "jwt_auth", jwt_instance): + with pytest.raises(AuthenticationFailed): + SSEAuthentication().authenticate(request) + + jwt_instance.get_validated_token.assert_called_once_with("bad-token") diff --git a/api/src/backend/api/tests/test_celery_settings.py b/api/src/backend/api/tests/test_celery_settings.py index d4010796ee..dcd8930edc 100644 --- a/api/src/backend/api/tests/test_celery_settings.py +++ b/api/src/backend/api/tests/test_celery_settings.py @@ -41,3 +41,30 @@ class TestBuildCeleryBrokerUrl: def test_invalid_scheme_raises_error(self): with pytest.raises(ValueError, match="Invalid VALKEY_SCHEME 'http'"): _build_celery_broker_url("http", "", "", "valkey", "6379", "0") + + +class TestCeleryWorkerConcurrency: + def _reimport_settings(self): + """Fresh import — importlib.reload() doesn't clear the module namespace, + so an attribute set by a prior test would leak into the unset case.""" + import sys + + sys.modules.pop("config.settings.celery", None) + import config.settings.celery as celery_settings + + return celery_settings + + def test_unset_leaves_setting_absent(self, monkeypatch): + monkeypatch.delenv("DJANGO_CELERY_WORKER_CONCURRENCY", raising=False) + mod = self._reimport_settings() + assert not hasattr(mod, "CELERY_WORKER_CONCURRENCY") + + def test_explicit_value_applied(self, monkeypatch): + monkeypatch.setenv("DJANGO_CELERY_WORKER_CONCURRENCY", "8") + mod = self._reimport_settings() + assert mod.CELERY_WORKER_CONCURRENCY == 8 + + def test_invalid_value_raises(self, monkeypatch): + monkeypatch.setenv("DJANGO_CELERY_WORKER_CONCURRENCY", "not-a-number") + with pytest.raises(ValueError): + self._reimport_settings() diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index 508e5abaca..99a31ea12c 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -10,6 +10,7 @@ from api.compliance import ( get_prowler_provider_checks, get_prowler_provider_compliance, load_prowler_checks, + warm_compliance_caches, ) from api.models import Provider from prowler.lib.check.compliance_models import ( @@ -267,11 +268,17 @@ def reset_compliance_cache(): """Reset the module-level cache so each test starts cold.""" previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS) compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() + # The warming flags are module-global; clear them so they do not leak + # between tests that call warm_compliance_caches. + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() try: yield finally: compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear() compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous) + compliance_module.COMPLIANCE_WARMING_STARTED.clear() + compliance_module.COMPLIANCE_WARMED.clear() class TestGetComplianceFrameworks: @@ -321,3 +328,89 @@ class TestGetComplianceFrameworks: f"loadable by get_bulk_compliance_frameworks_universal: " f"{sorted(missing)}" ) + + +class TestWarmComplianceCaches: + def test_warms_all_provider_types_by_default(self, reset_compliance_cache): + provider_types = list(Provider.ProviderChoices.values) + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches() + + warmed = {call.args[0] for call in mock_frameworks.call_args_list} + assert warmed == set(provider_types) + assert mock_frameworks.call_count == len(provider_types) + assert mock_ensure.call_count == len(provider_types) + + def test_warms_only_requested_provider_types(self, reset_compliance_cache): + with ( + patch("api.compliance.get_compliance_frameworks") as mock_frameworks, + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + mock_frameworks.assert_called_once_with(Provider.ProviderChoices.AWS) + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_populates_module_cache(self, reset_compliance_cache): + with ( + patch( + "api.compliance.get_bulk_compliance_frameworks_universal" + ) as mock_get_bulk, + patch("api.compliance._ensure_provider_loaded"), + ): + mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()} + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert ( + Provider.ProviderChoices.AWS + in compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS + ) + + def test_failing_provider_does_not_abort_the_rest(self, reset_compliance_cache): + """A failing provider (even on SystemExit) is isolated; others warm.""" + providers = [Provider.ProviderChoices.AWS, Provider.ProviderChoices.OKTA] + + def fake_frameworks(provider_type): + if provider_type == Provider.ProviderChoices.OKTA: + raise SystemExit(1) + return [] + + with ( + patch( + "api.compliance.get_compliance_frameworks", side_effect=fake_frameworks + ), + patch("api.compliance._ensure_provider_loaded") as mock_ensure, + ): + failed = warm_compliance_caches(providers) + + assert failed == [Provider.ProviderChoices.OKTA] + mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS) + + def test_sets_readiness_flags(self, reset_compliance_cache): + assert not compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert not compliance_module.COMPLIANCE_WARMED.is_set() + + with ( + patch("api.compliance.get_compliance_frameworks"), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMING_STARTED.is_set() + assert compliance_module.COMPLIANCE_WARMED.is_set() + + def test_marks_warmed_even_when_a_provider_fails(self, reset_compliance_cache): + """A failed provider still leaves the caches flagged as warmed.""" + with ( + patch( + "api.compliance.get_compliance_frameworks", + side_effect=SystemExit(1), + ), + patch("api.compliance._ensure_provider_loaded"), + ): + warm_compliance_caches([Provider.ProviderChoices.AWS]) + + assert compliance_module.COMPLIANCE_WARMED.is_set() diff --git a/api/src/backend/api/tests/test_sse.py b/api/src/backend/api/tests/test_sse.py new file mode 100644 index 0000000000..beba821e64 --- /dev/null +++ b/api/src/backend/api/tests/test_sse.py @@ -0,0 +1,191 @@ +"""Tests for the platform SSE infrastructure (``api.sse``). + +Cover the two security-critical platform pieces — the channel-name +convention (:mod:`api.sse.utils`) and the tenant gate enforced by +:class:`api.sse.channelmanager.SSEChannelManager`. The SSE authentication +class lives in :mod:`api.authentication` with the rest of the auth stack, +so its tests live in ``test_authentication.py``. Per-feature SSE endpoints +add their own tests on top of these. +""" + +import uuid +from unittest.mock import MagicMock + +import pytest +from django.http import StreamingHttpResponse +from rest_framework.test import APIRequestFactory, force_authenticate + +from api.sse.base_views import BaseSSEViewSet +from api.sse.channelmanager import SSEChannelManager +from api.sse.utils import make_channel_name, tenant_id_from_channel + + +class TestMakeChannel: + def test_round_trips_tenant_id(self): + tenant_id = uuid.uuid4() + channel = make_channel_name("lighthouse-session", tenant_id, uuid.uuid4()) + assert tenant_id_from_channel(channel) == tenant_id + + def test_accepts_str_arguments(self): + tenant_id = uuid.uuid4() + channel = make_channel_name("lighthouse-session", str(tenant_id), "resource-1") + assert channel == f"lighthouse-session:{tenant_id}:resource-1" + + def test_prefix_with_hyphen_is_not_split(self): + # Prefixes contain hyphens but never colons, so the tenant id is + # always the second colon-separated segment. + tenant_id = uuid.uuid4() + channel = make_channel_name("a-long-hyphenated-prefix", tenant_id, "res") + assert tenant_id_from_channel(channel) == tenant_id + + @pytest.mark.parametrize( + "prefix, tenant_id, resource_id", + [ + ("evil:prefix", uuid.uuid4(), "res"), + ("prefix", uuid.uuid4(), "res:extra"), + ("prefix", "tenant:smuggled", "res"), + ], + ) + def test_rejects_separator_injection(self, prefix, tenant_id, resource_id): + # A colon in any segment would let a crafted name smuggle extra + # segments past the parser, so construction must fail loudly. + with pytest.raises(ValueError): + make_channel_name(prefix, tenant_id, resource_id) + + +class TestTenantIdFromChannel: + def test_returns_none_for_too_few_segments(self): + assert tenant_id_from_channel("prefix:only") is None + assert tenant_id_from_channel("garbage") is None + + def test_returns_none_for_too_many_segments(self): + # A valid tenant UUID in position 1 must not authorize a + # non-canonical name that carries extra segments. + tenant_id = uuid.uuid4() + assert tenant_id_from_channel(f"prefix:{tenant_id}:resource:extra") is None + + def test_returns_none_for_non_uuid_tenant_segment(self): + assert tenant_id_from_channel("prefix:not-a-uuid:resource") is None + + def test_parses_valid_channel(self): + tenant_id = uuid.uuid4() + assert tenant_id_from_channel(f"prefix:{tenant_id}:resource") == tenant_id + + +@pytest.mark.django_db +class TestSSEChannelManager: + def test_member_can_read_own_tenant_channel( + self, create_test_user, tenants_fixture + ): + tenant = tenants_fixture[0] + channel = make_channel_name("lighthouse-session", tenant.id, uuid.uuid4()) + assert SSEChannelManager().can_read_channel(create_test_user, channel) + + def test_non_member_cannot_read_other_tenant_channel( + self, create_test_user, tenants_fixture + ): + # create_test_user is a member of tenant1 and tenant2 but not tenant3. + foreign_tenant = tenants_fixture[2] + channel = make_channel_name( + "lighthouse-session", foreign_tenant.id, uuid.uuid4() + ) + assert not SSEChannelManager().can_read_channel(create_test_user, channel) + + def test_anonymous_user_is_rejected(self, tenants_fixture): + channel = make_channel_name( + "lighthouse-session", tenants_fixture[0].id, uuid.uuid4() + ) + assert not SSEChannelManager().can_read_channel(None, channel) + + anon = MagicMock(is_authenticated=False) + assert not SSEChannelManager().can_read_channel(anon, channel) + + def test_malformed_channel_is_rejected(self, create_test_user, tenants_fixture): + assert not SSEChannelManager().can_read_channel(create_test_user, "garbage") + + def test_get_channels_for_request_returns_active_tenant_channels(self): + tenant_id = uuid.uuid4() + own = make_channel_name("prefix", tenant_id, "resource") + request = MagicMock() + request.tenant_id = str(tenant_id) + request.sse_channels = {own} + assert SSEChannelManager().get_channels_for_request(request, {}) == {own} + + def test_get_channels_for_request_drops_other_tenant_channels(self): + # Fail-closed: a channel for a tenant other than the active JWT + # tenant is dropped before reaching django-eventstream, even if the + # viewset mistakenly stashed it. This is the primary tenant gate that + # binds authorization to request.tenant_id, not just membership. + active_tenant = uuid.uuid4() + own = make_channel_name("prefix", active_tenant, "resource") + foreign = make_channel_name("prefix", uuid.uuid4(), "resource") + request = MagicMock() + request.tenant_id = str(active_tenant) + request.sse_channels = {own, foreign} + assert SSEChannelManager().get_channels_for_request(request, {}) == {own} + + def test_get_channels_for_request_drops_malformed_channels(self): + request = MagicMock() + request.tenant_id = str(uuid.uuid4()) + request.sse_channels = {"garbage", "prefix:not-a-uuid:resource"} + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_get_channels_for_request_without_tenant_returns_empty(self): + # No active tenant on the request (auth/RLS never ran) → fail closed, + # regardless of any channels stashed on it. + request = MagicMock(spec=[]) + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_get_channels_for_request_defaults_to_empty(self): + # A request that never went through BaseSSEViewSet.list has no + # sse_channels attribute; the manager must not raise. + request = object() + assert SSEChannelManager().get_channels_for_request(request, {}) == set() + + def test_channel_is_not_reliable(self): + # v1 ships without server-side replay storage. + assert ( + SSEChannelManager().is_channel_reliable("prefix:tenant:resource") is False + ) + + +@pytest.mark.django_db +class TestBaseSSEViewSet: + """End-to-end check that the base viewset opens a stream. + + ``BaseSSEViewSet.list`` hands the DRF ``Request`` straight to + django-eventstream's ``events()``, which is written for a plain + Django request. This drives a real request through the full DRF + stack (authentication, RLS, content negotiation, channel manager) + and asserts the result is an SSE stream, so the DRF/Django request + mismatch cannot regress silently. + """ + + def test_list_opens_event_stream(self, create_test_user, tenants_fixture): + tenant = tenants_fixture[0] + channel = make_channel_name("test-sse", tenant.id, uuid.uuid4()) + seen_tenant_ids = [] + + class _StreamingSSEViewSet(BaseSSEViewSet): + def get_channels(self): + # Reached only after dispatch/initial ran, so the RLS + # tenant context is already on the request. + seen_tenant_ids.append(self.request.tenant_id) + return {channel} + + request = APIRequestFactory().get("/api/v1/test-sse/stream") + force_authenticate( + request, user=create_test_user, token={"tenant_id": str(tenant.id)} + ) + + view = _StreamingSSEViewSet.as_view({"get": "list"}) + response = view(request) + + # A StreamingHttpResponse (not the plain HttpResponse used for SSE + # error envelopes) means events() accepted the DRF request, the + # channel manager handed it a non-empty channel set, and the + # stream was opened end to end. + assert isinstance(response, StreamingHttpResponse) + assert response.status_code == 200 + assert response["Content-Type"] == "text/event-stream" + assert seen_tenant_ids == [str(tenant.id)] diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 3a7f030a9c..3fd9235dac 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -357,6 +357,30 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result + def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set( + self, + ): + secret_dict = { + "user": "ocid1.user.oc1..fake", + "fingerprint": "00:11:22:33:44:55:66:77", + "key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----", + "tenancy": "ocid1.tenancy.oc1..fake", + "region": "us-ashburn-1", + "pass_phrase": "fake-passphrase", + } + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.ORACLECLOUD.value + provider.secret = secret_mock + provider.uid = "ocid1.tenancy.oc1..fake" + + result = get_prowler_provider_kwargs(provider) + + expected_result = {**secret_dict, "region": {"us-ashburn-1"}} + assert result == expected_result + def test_get_prowler_provider_kwargs_with_mutelist(self): provider_uid = "provider_uid" secret_dict = {"key": "value"} diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index d213e8c855..613cef4755 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -1411,6 +1411,42 @@ class TestProviderViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_providers_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("provider-list"), {"filter[provider_groups]": str(group1.id)} + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert [item["id"] for item in data] == [str(provider1.id)] + + response = authenticated_client.get( + reverse("provider-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + provider_ids = {item["id"] for item in response.json()["data"]} + assert provider_ids == {str(provider1.id), str(provider2.id)} + assert len(response.json()["data"]) == 2 + def test_providers_disable_pagination( self, authenticated_client, providers_fixture, tenants_fixture ): @@ -3715,6 +3751,41 @@ class TestScanViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count + def test_scans_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + scans_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + scan1, scan2, *_ = scans_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan1.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan1.provider, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=scan2.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("scan-list"), {"filter[provider_groups]": str(group1.id)} + ) + assert response.status_code == status.HTTP_200_OK + assert {item["id"] for item in response.json()["data"]} == {str(scan1.id)} + + response = authenticated_client.get( + reverse("scan-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + scan_ids = {item["id"] for item in response.json()["data"]} + assert scan_ids == {str(scan1.id), str(scan2.id), str(scans_fixture[2].id)} + assert len(response.json()["data"]) == 3 + @pytest.mark.parametrize( "filter_name", [ @@ -5996,6 +6067,49 @@ class TestResourceViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count + def test_resource_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + resources_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + resource1, resource2, resource3, *_ = resources_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource1.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource1.provider, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=resource3.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("resource-list"), + {"filter[updated_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + assert {item["id"] for item in response.json()["data"]} == { + str(resource1.id), + str(resource2.id), + } + + response = authenticated_client.get( + reverse("resource-list"), + { + "filter[updated_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + resource_ids = {item["id"] for item in response.json()["data"]} + assert resource_ids == {str(resource1.id), str(resource2.id), str(resource3.id)} + assert len(response.json()["data"]) == 3 + def test_resource_filter_by_scan_id( self, authenticated_client, resources_fixture, scans_fixture ): @@ -7308,6 +7422,40 @@ class TestFindingViewSet: assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 2 + def test_finding_filter_provider_groups( + self, + authenticated_client, + tenants_fixture, + findings_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + finding1, finding2, *_ = findings_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=finding1.scan.provider, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=finding1.scan.provider, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-list"), + {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + response = authenticated_client.get( + reverse("finding-list"), + { + "filter[inserted_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + @pytest.mark.parametrize( "filter_name", ( @@ -9278,6 +9426,118 @@ class TestComplianceOverviewViewSet: with patch("api.v1.views.backfill_compliance_summaries_task.delay") as mock: yield mock + def _create_completed_scan(self, provider, name): + return Scan.objects.create( + name=name, + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=provider.tenant_id, + started_at=datetime.now(timezone.utc), + completed_at=datetime.now(timezone.utc), + ) + + def _create_requirement( + self, + scan, + requirement_id, + status_choice, + region="eu-west-1", + compliance_id="cis_1.4_aws", + ): + passed = 1 if status_choice == StatusChoices.PASS else 0 + total = 1 if status_choice != StatusChoices.MANUAL else 0 + return ComplianceRequirementOverview.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + compliance_id=compliance_id, + framework="CIS-1.4-AWS", + version="1.4", + description="CIS AWS Foundations Benchmark v1.4.0", + region=region, + requirement_id=requirement_id, + requirement_status=status_choice, + passed_checks=passed, + failed_checks=0 + if status_choice in (StatusChoices.PASS, StatusChoices.MANUAL) + else 1, + total_checks=total, + passed_findings=passed, + total_findings=total, + ) + + def _create_compliance_summary( + self, + scan, + *, + passed, + failed, + manual=0, + compliance_id="cis_1.4_aws", + ): + return ComplianceOverviewSummary.objects.create( + tenant_id=scan.tenant_id, + scan=scan, + compliance_id=compliance_id, + requirements_passed=passed, + requirements_failed=failed, + requirements_manual=manual, + total_requirements=passed + failed + manual, + ) + + def _overview_attrs_by_id(self, response): + assert response.status_code == status.HTTP_200_OK + return {item["id"]: item["attributes"] for item in response.json()["data"]} + + def _prepare_latest_compliance_data(self, providers_fixture): + provider1, provider2, provider3, *_ = providers_fixture + old_scan = self._create_completed_scan(provider1, "old aws compliance scan") + latest_scan1 = self._create_completed_scan( + provider1, "latest aws compliance scan 1" + ) + latest_scan2 = self._create_completed_scan( + provider2, "latest aws compliance scan 2" + ) + latest_gcp_scan = self._create_completed_scan( + provider3, "latest gcp compliance scan" + ) + + self._create_requirement(old_scan, "1.1", StatusChoices.FAIL) + self._create_requirement(old_scan, "1.2", StatusChoices.FAIL) + self._create_compliance_summary(old_scan, passed=0, failed=2) + + self._create_requirement( + latest_scan1, "1.1", StatusChoices.PASS, region="eu-west-1" + ) + self._create_requirement( + latest_scan1, "1.2", StatusChoices.PASS, region="eu-west-1" + ) + self._create_compliance_summary(latest_scan1, passed=2, failed=0) + + self._create_requirement( + latest_scan2, "1.1", StatusChoices.FAIL, region="us-east-1" + ) + self._create_requirement( + latest_scan2, "1.2", StatusChoices.PASS, region="us-east-1" + ) + self._create_compliance_summary(latest_scan2, passed=1, failed=1) + + self._create_requirement( + latest_gcp_scan, + "gcp-1.1", + StatusChoices.FAIL, + region="europe-west1", + compliance_id="cis_1.3_gcp", + ) + self._create_compliance_summary( + latest_gcp_scan, + passed=0, + failed=1, + compliance_id="cis_1.3_gcp", + ) + + return old_scan, latest_scan1, latest_scan2, latest_gcp_scan + def test_compliance_overview_list_none( self, authenticated_client, @@ -9425,6 +9685,283 @@ class TestComplianceOverviewViewSet: assert len(response.json()["data"]) >= 1 mock_backfill_task.assert_not_called() + def test_compliance_overview_provider_id_filter_uses_latest_scan( + self, + authenticated_client, + providers_fixture, + mock_backfill_task, + ): + _, latest_scan, *_ = self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_id]": str(latest_scan.provider_id)}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0 + assert "cis_1.3_gcp" not in attrs_by_id + mock_backfill_task.assert_not_called() + + def test_compliance_overview_provider_id_in_filter_aggregates_latest_scans( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + { + "filter[provider_id__in]": ( + f"{latest_scan1.provider_id},{latest_scan2.provider_id}" + ) + }, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + assert "cis_1.3_gcp" not in attrs_by_id + + def test_compliance_overview_provider_type_filter_uses_latest_scans( + self, + authenticated_client, + providers_fixture, + ): + self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_type]": Provider.ProviderChoices.AWS.value}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + assert "cis_1.3_gcp" not in attrs_by_id + + def test_compliance_overview_provider_groups_filters_use_latest_scans( + self, + authenticated_client, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider=provider1, + provider_group=group1, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider=provider2, + provider_group=group2, + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_groups]": str(group1.id)}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 2 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 0 + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + + attrs_by_id = self._overview_attrs_by_id(response) + assert attrs_by_id["cis_1.4_aws"]["requirements_passed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["requirements_failed"] == 1 + assert attrs_by_id["cis_1.4_aws"]["total_requirements"] == 2 + + def _assert_latest_provider_scan_task_response( + self, + authenticated_client, + endpoint, + scan, + query_params=None, + ): + query_params = {**(query_params or {})} + if not any(key.startswith("filter[provider_") for key in query_params): + query_params = { + "filter[provider_id]": str(scan.provider_id), + **query_params, + } + + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_task_response.return_value = Response( + {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED + ) + + response = authenticated_client.get(reverse(endpoint), query_params) + + assert response.status_code == status.HTTP_202_ACCEPTED + mock_task_response.assert_called_once() + _, kwargs = mock_task_response.call_args + assert kwargs["task_name"] == "scan-compliance-overviews" + assert str(kwargs["task_kwargs"]["tenant_id"]) == str(scan.tenant_id) + assert str(kwargs["task_kwargs"]["scan_id"]) == str(scan.id) + assert kwargs["raise_on_not_found"] is False + + def test_compliance_overview_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance data" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-list", + scan, + ) + + def test_compliance_overview_provider_filter_returns_running_task_for_partial_data( + self, + authenticated_client, + providers_fixture, + ): + provider_with_data, provider_without_data, *_ = providers_fixture + scan_with_data = self._create_completed_scan( + provider_with_data, "latest scan with compliance data" + ) + scan_without_data = self._create_completed_scan( + provider_without_data, "latest scan without partial compliance data" + ) + self._create_requirement(scan_with_data, "1.1", StatusChoices.PASS) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-list", + scan_without_data, + { + "filter[provider_id__in]": ( + f"{provider_with_data.id},{provider_without_data.id}" + ) + }, + ) + + def test_compliance_overview_provider_filter_empty_response_uses_scan_data_presence( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan with filtered compliance data" + ) + self._create_requirement(scan, "1.1", StatusChoices.PASS, region="eu-west-1") + + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_task_response.return_value = Response( + {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED + ) + + response = authenticated_client.get( + reverse("complianceoverview-list"), + { + "filter[provider_id]": str(scan.provider_id), + "filter[region]": "us-east-1", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + mock_task_response.assert_not_called() + + def test_compliance_overview_metadata_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance metadata" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-metadata", + scan, + ) + + def test_compliance_overview_requirements_provider_filter_returns_running_task_without_data( + self, + authenticated_client, + providers_fixture, + ): + scan = self._create_completed_scan( + providers_fixture[0], "latest scan without compliance requirements" + ) + + self._assert_latest_provider_scan_task_response( + authenticated_client, + "complianceoverview-requirements", + scan, + {"filter[compliance_id]": "cis_1.4_aws"}, + ) + + def test_compliance_overview_metadata_accepts_provider_filters( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan, *_ = self._prepare_latest_compliance_data(providers_fixture) + + response = authenticated_client.get( + reverse("complianceoverview-metadata"), + {"filter[provider_id]": str(latest_scan.provider_id)}, + ) + + assert response.status_code == status.HTTP_200_OK + regions = response.json()["data"]["attributes"]["regions"] + assert regions == ["eu-west-1"] + + def test_compliance_overview_requirements_accepts_provider_filters( + self, + authenticated_client, + providers_fixture, + ): + _, latest_scan1, latest_scan2, *_ = self._prepare_latest_compliance_data( + providers_fixture + ) + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + { + "filter[provider_id__in]": ( + f"{latest_scan1.provider_id},{latest_scan2.provider_id}" + ), + "filter[compliance_id]": "cis_1.4_aws", + }, + ) + + assert response.status_code == status.HTTP_200_OK + requirements_by_id = { + item["id"]: item["attributes"] for item in response.json()["data"] + } + assert requirements_by_id["1.1"]["status"] == "FAIL" + assert requirements_by_id["1.2"]["status"] == "PASS" + def test_compliance_overview_metadata( self, authenticated_client, compliance_requirements_overviews_fixture ): @@ -9570,6 +10107,188 @@ class TestComplianceOverviewViewSet: assert "Category" in first_attr assert "AWSService" in first_attr + def test_compliance_overview_attributes_resolves_provider_from_scan( + self, authenticated_client, tenants_fixture, providers_fixture + ): + # csa_ccm_4.0 is a multi-provider universal framework: a single + # compliance_id whose requirements expose different checks per provider. + # Passing a scan must return the check IDs for that scan's provider, + # otherwise the endpoint defaults to the first provider that declares the + # framework and azure/gcp requirements end up with check IDs that match + # no findings. + tenant = tenants_fixture[0] + gcp_provider = providers_fixture[2] + azure_provider = providers_fixture[4] + assert gcp_provider.provider == Provider.ProviderChoices.GCP.value + assert azure_provider.provider == Provider.ProviderChoices.AZURE.value + + now = datetime.now(timezone.utc) + gcp_scan = Scan.objects.create( + name="gcp scan", + provider=gcp_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + azure_scan = Scan.objects.create( + name="azure scan", + provider=azure_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id=None): + params = {"filter[compliance_id]": "csa_ccm_4.0"} + if scan_id is not None: + params["filter[scan_id]"] = str(scan_id) + return authenticated_client.get( + reverse("complianceoverview-attributes"), params + ) + + def collect_check_ids(scan_id=None): + response = request_attributes(scan_id) + assert response.status_code == status.HTTP_200_OK + check_ids = set() + for item in response.json()["data"]: + check_ids.update(item["attributes"]["attributes"]["check_ids"]) + return check_ids + + gcp_check_ids = collect_check_ids(gcp_scan.id) + azure_check_ids = collect_check_ids(azure_scan.id) + + # Each scan resolves to its own provider's checks, and they differ. + assert gcp_check_ids + assert azure_check_ids + assert gcp_check_ids != azure_check_ids + + # The returned check IDs belong to the SDK's per-provider definition. + from api.compliance import get_prowler_provider_compliance + + def expected_check_ids(provider_type): + framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"] + expected = set() + for requirement in framework.requirements: + expected.update(requirement.checks.get(provider_type, [])) + return expected + + assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value) + assert azure_check_ids <= expected_check_ids( + Provider.ProviderChoices.AZURE.value + ) + + # An explicit scan_id is authoritative: a non-existent scan must fail + # closed with 404 instead of silently falling back to another provider. + missing_response = request_attributes("00000000-0000-0000-0000-000000000000") + assert missing_response.status_code == status.HTTP_404_NOT_FOUND + + # A malformed scan_id is rejected with 404 as well. + malformed_response = request_attributes("not-a-uuid") + assert malformed_response.status_code == status.HTTP_404_NOT_FOUND + + # An empty value (filter[scan_id]=) must not fall back to the legacy + # provider picker: the explicit (if blank) selector fails closed. + empty_response = request_attributes("") + assert empty_response.status_code == status.HTTP_404_NOT_FOUND + + # A scan belonging to another tenant is not visible (RLS), so it must + # return 404 rather than leaking the fallback provider's check IDs. + other_tenant = Tenant.objects.create(name="Other Compliance Tenant") + foreign_provider = Provider.objects.create( + provider="gcp", + uid="foreign-gcp-test", + alias="foreign_gcp", + tenant_id=other_tenant.id, + ) + foreign_scan = Scan.objects.create( + name="foreign scan", + provider=foreign_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=other_tenant.id, + started_at=now, + completed_at=now, + ) + foreign_response = request_attributes(foreign_scan.id) + assert foreign_response.status_code == status.HTTP_404_NOT_FOUND + + def test_compliance_overview_attributes_scan_scoped_by_provider_group( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + ): + # A user with limited visibility (no UNLIMITED_VISIBILITY) must only be + # able to resolve scans for providers in its provider groups. Tenant RLS + # alone is not enough here: both scans belong to the same tenant, so the + # endpoint has to scope the scan lookup by provider group, otherwise a + # restricted user could read another provider's compliance metadata. + client = authenticated_client_no_permissions_rbac + limited_user = client.user + membership = Membership.objects.filter(user=limited_user).first() + tenant = membership.tenant + + allowed_provider = providers_fixture[2] + denied_provider = providers_fixture[4] + assert allowed_provider.provider == Provider.ProviderChoices.GCP.value + assert denied_provider.provider == Provider.ProviderChoices.AZURE.value + + provider_group = ProviderGroup.objects.create( + name="limited-compliance-group", + tenant_id=tenant.id, + ) + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=limited_user.roles.first(), + provider_group=provider_group, + ) + + now = datetime.now(timezone.utc) + allowed_scan = Scan.objects.create( + name="allowed scan", + provider=allowed_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + denied_scan = Scan.objects.create( + name="denied scan", + provider=denied_provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant.id, + started_at=now, + completed_at=now, + ) + + def request_attributes(scan_id): + return client.get( + reverse("complianceoverview-attributes"), + { + "filter[compliance_id]": "csa_ccm_4.0", + "filter[scan_id]": str(scan_id), + }, + ) + + # The scan in the user's provider group resolves normally. + assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK + + # The scan outside the user's provider group is invisible, so it fails + # closed with 404 instead of leaking the other provider's check IDs. + assert ( + request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND + ) + def test_compliance_overview_attributes_missing_compliance_id( self, authenticated_client ): @@ -9578,6 +10297,39 @@ class TestComplianceOverviewViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_compliance_overview_attributes_503_while_warming( + self, authenticated_client + ): + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.set() + COMPLIANCE_WARMED.clear() + try: + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + finally: + COMPLIANCE_WARMING_STARTED.clear() + + assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE + assert response.json()["errors"][0]["code"] == "compliance_warming" + + def test_compliance_overview_attributes_serves_when_warming_not_started( + self, authenticated_client + ): + # Dev fallback: under runserver warming never runs, so the guard must + # not refuse — the endpoint lazily loads and serves as before. + from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED + + COMPLIANCE_WARMING_STARTED.clear() + COMPLIANCE_WARMED.clear() + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + assert response.status_code == status.HTTP_200_OK + def test_compliance_overview_task_management_integration( self, authenticated_client, compliance_requirements_overviews_fixture ): @@ -9816,6 +10568,40 @@ class TestOverviewViewSet: for entry in grouped_data: assert "findings" not in entry["attributes"] + def test_overview_providers_count_applies_limited_visibility( + self, + authenticated_client_no_permissions_rbac, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + client = authenticated_client_no_permissions_rbac + allowed_provider = providers_fixture[2] + denied_provider = providers_fixture[4] + provider_group = provider_groups_fixture[0] + + ProviderGroupMembership.objects.create( + tenant_id=tenant.id, + provider_group=provider_group, + provider=allowed_provider, + ) + RoleProviderGroupRelationship.objects.create( + tenant_id=tenant.id, + role=client.user.roles.first(), + provider_group=provider_group, + ) + + response = client.get(reverse("overview-providers-count")) + + assert response.status_code == status.HTTP_200_OK + aggregated = { + entry["id"]: entry["attributes"]["count"] + for entry in response.json()["data"] + } + assert aggregated == {allowed_provider.provider: 1} + assert denied_provider.provider not in aggregated + def _create_scan(self, tenant, provider, name, started_at=None): scan_started = started_at or datetime.now(timezone.utc) - timedelta(hours=1) return Scan.objects.create( @@ -10363,6 +11149,87 @@ class TestOverviewViewSet: assert combined_attributes["muted"] == 3 assert combined_attributes["total"] == 14 + def test_overview_findings_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + scan1 = Scan.objects.create( + name="scan-provider-group-one", + provider=provider1, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + scan2 = Scan.objects.create( + name="scan-provider-group-two", + provider=provider2, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant=tenant, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan1, + check_id="check-provider-group-one", + service="service-a", + severity="high", + region="region-a", + _pass=5, + fail=1, + muted=2, + total=8, + ) + ScanSummary.objects.create( + tenant=tenant, + scan=scan2, + check_id="check-provider-group-two", + service="service-b", + severity="medium", + region="region-b", + _pass=2, + fail=3, + muted=1, + total=6, + ) + + response = authenticated_client.get( + reverse("overview-findings"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert attributes["pass"] == 5 + assert attributes["fail"] == 1 + assert attributes["muted"] == 2 + assert attributes["total"] == 8 + + response = authenticated_client.get( + reverse("overview-findings"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + assert attributes["pass"] == 7 + assert attributes["fail"] == 4 + assert attributes["muted"] == 3 + assert attributes["total"] == 14 + def test_overview_findings_severity_provider_id_in_filter( self, authenticated_client, tenants_fixture, providers_fixture ): @@ -11131,9 +11998,21 @@ class TestOverviewViewSet: @pytest.mark.parametrize( "filter_key,filter_value_fn,expected_total,expected_failed", [ - ("filter[provider_id]", lambda p1, _: str(p1.id), 10, 5), + ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5), ("filter[provider_type]", lambda *_: "aws", 10, 5), ("filter[provider_type__in]", lambda *_: "aws,gcp", 30, 20), + ( + "filter[provider_groups]", + lambda p1, _, group1, __: str(group1.id), + 10, + 5, + ), + ( + "filter[provider_groups__in]", + lambda p1, _, group1, group2: f"{group1.id},{group2.id}", + 30, + 20, + ), ], ) def test_overview_categories_filters( @@ -11141,6 +12020,7 @@ class TestOverviewViewSet: authenticated_client, tenants_fixture, providers_fixture, + provider_groups_fixture, create_scan_category_summary, filter_key, filter_value_fn, @@ -11149,6 +12029,16 @@ class TestOverviewViewSet: ): tenant = tenants_fixture[0] provider1, _, gcp_provider, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=gcp_provider, provider_group=group2 + ) scan1 = Scan.objects.create( name="categories-scan-1", @@ -11174,7 +12064,7 @@ class TestOverviewViewSet: response = authenticated_client.get( reverse("overview-categories"), - {filter_key: filter_value_fn(provider1, gcp_provider)}, + {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)}, ) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] @@ -11348,10 +12238,22 @@ class TestOverviewViewSet: @pytest.mark.parametrize( "filter_key,filter_value_fn,expected_total,expected_failed", [ - ("filter[provider_id]", lambda p1, p2: str(p1.id), 10, 5), - ("filter[provider_id__in]", lambda p1, p2: f"{p1.id},{p2.id}", 25, 12), - ("filter[provider_type]", lambda p1, p2: "aws", 10, 5), - ("filter[provider_type__in]", lambda p1, p2: "aws,gcp", 25, 12), + ("filter[provider_id]", lambda p1, *_: str(p1.id), 10, 5), + ("filter[provider_id__in]", lambda p1, p2, *_: f"{p1.id},{p2.id}", 25, 12), + ("filter[provider_type]", lambda *_: "aws", 10, 5), + ("filter[provider_type__in]", lambda *_: "aws,gcp", 25, 12), + ( + "filter[provider_groups]", + lambda p1, p2, group1, group2: str(group1.id), + 10, + 5, + ), + ( + "filter[provider_groups__in]", + lambda p1, p2, group1, group2: f"{group1.id},{group2.id}", + 25, + 12, + ), ], ) def test_overview_groups_provider_filters( @@ -11359,6 +12261,7 @@ class TestOverviewViewSet: authenticated_client, tenants_fixture, providers_fixture, + provider_groups_fixture, create_scan_resource_group_summary, filter_key, filter_value_fn, @@ -11368,6 +12271,16 @@ class TestOverviewViewSet: tenant = tenants_fixture[0] provider1 = providers_fixture[0] # AWS gcp_provider = providers_fixture[2] # GCP + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=gcp_provider, provider_group=group2 + ) scan1 = Scan.objects.create( name="aws-rg-scan", @@ -11393,7 +12306,7 @@ class TestOverviewViewSet: response = authenticated_client.get( reverse("overview-resource-groups"), - {filter_key: filter_value_fn(provider1, gcp_provider)}, + {filter_key: filter_value_fn(provider1, gcp_provider, group1, group2)}, ) assert response.status_code == status.HTTP_200_OK data = response.json()["data"] @@ -11568,6 +12481,49 @@ class TestOverviewViewSet: data = response.json()["data"] assert len(data) >= 1 + def test_compliance_watchlist_provider_groups_filter( + self, + authenticated_client, + provider_compliance_scores_fixture, + providers_fixture, + provider_groups_fixture, + tenants_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("overview-compliance-watchlist"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + by_id = {item["id"]: item["attributes"] for item in data} + assert by_id["aws_cis_2.0"]["requirements_passed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_failed"] == 1 + assert by_id["aws_cis_2.0"]["requirements_manual"] == 1 + + response = authenticated_client.get( + reverse("overview-compliance-watchlist"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + by_id = {item["id"]: item["attributes"] for item in data} + assert by_id["aws_cis_2.0"]["requirements_passed"] == 0 + assert by_id["aws_cis_2.0"]["requirements_failed"] == 2 + assert by_id["aws_cis_2.0"]["requirements_manual"] == 1 + def test_compliance_watchlist_empty_result(self, authenticated_client): response = authenticated_client.get(reverse("overview-compliance-watchlist")) assert response.status_code == status.HTTP_200_OK @@ -12625,12 +13581,14 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["no_permissions"], + "userType": ["platform_team"], }, ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -12650,18 +13608,23 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace( tenant_id=tenants_fixture[0].id ) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenants_fixture[0] + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -12680,12 +13643,21 @@ class TestTenantFinishACSView: assert user.name == "John Doe" assert user.company_name == "testing_company" - role = Role.objects.using(MainRouter.admin_db).get(name="no_permissions") + role = Role.objects.using(MainRouter.admin_db).get( + name="platform_team", tenant=tenants_fixture[0] + ) assert role.tenant == tenants_fixture[0] + assert not role.manage_users + assert not role.manage_account + assert not role.manage_billing + assert not role.manage_providers + assert not role.manage_integrations + assert not role.manage_scans + assert role.unlimited_visibility assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(user=user, tenant_id=tenants_fixture[0].id) + .filter(user=user, role=role, tenant_id=tenants_fixture[0].id) .exists() ) @@ -12700,6 +13672,81 @@ class TestTenantFinishACSView: user.company_name = original_company user.save() + def test_dispatch_rejects_assertion_email_domain_that_differs_from_slug( + self, tenants_fixture, saml_setup, monkeypatch + ): + monkeypatch.setenv("AUTH_URL", "http://localhost") + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + victim_tenant = tenants_fixture[0] + attacker_tenant = tenants_fixture[1] + attacker_domain = "attacker.com" + + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=attacker_domain, + metadata_xml=""" + + + + + + TEST + + + + + + + """, + tenant=attacker_tenant, + ) + user = User.objects.using(MainRouter.admin_db).create( + email=f"intruder@{saml_setup['domain']}", name="Intruder" + ) + social_account = SocialAccount( + user=user, + provider="ATTACKER", + extra_data={ + "firstName": ["Mallory"], + "lastName": ["Example"], + }, + ) + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": attacker_domain}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", + provider_id="ATTACKER", + client_id=attacker_domain, + name="Attacker App", + settings={}, + ) + mock_sa_get.return_value = social_account + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug=attacker_domain) + + assert response.status_code == 302 + assert "sso_saml_failed=true" in response.url + assert not ( + Membership.objects.using(MainRouter.admin_db) + .filter(user=user, tenant=victim_tenant) + .exists() + ) + assert ( + not SAMLToken.objects.using(MainRouter.admin_db).filter(user=user).exists() + ) + def test_rollback_saml_user_when_error_occurs(self, users_fixture, monkeypatch): """Test that a user is properly deleted when created during SAML flow and an error occurs""" monkeypatch.setenv("AUTH_URL", "http://localhost") @@ -12738,7 +13785,7 @@ class TestTenantFinishACSView: assert response.status_code == 302 assert "sso_saml_failed=true" in response.url - def test_dispatch_skips_role_mapping_when_single_manage_account_user( + def test_dispatch_keeps_existing_roles_when_usertype_missing( self, create_test_user, tenants_fixture, @@ -12747,7 +13794,7 @@ class TestTenantFinishACSView: settings, monkeypatch, ): - """Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role""" + """Test that roles are left untouched when the IdP does not send userType""" monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") user = create_test_user tenant = tenants_fixture[0] @@ -12756,6 +13803,7 @@ class TestTenantFinishACSView: UserRoleRelationship.objects.using(MainRouter.admin_db).create( user=user, role=admin_role, tenant_id=tenant.id ) + roles_before = Role.objects.using(MainRouter.admin_db).count() social_account = SocialAccount( user=user, @@ -12764,12 +13812,13 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["no_permissions"], # This should be ignored }, ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -12789,37 +13838,194 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 - # Verify the admin role is still assigned (not changed to no_permissions) + # Verify the existing role assignment was not modified assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) .filter(user=user, role=admin_role, tenant_id=tenant.id) .exists() ) - # Verify no_permissions role was NOT created in the database - assert ( - not Role.objects.using(MainRouter.admin_db) - .filter(name="no_permissions", tenant=tenant) + # Verify no new role was created + assert Role.objects.using(MainRouter.admin_db).count() == roles_before + + def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing( + self, + create_test_user, + tenants_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that a user without roles gets none assigned when userType is missing""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + roles_before = Role.objects.using(MainRouter.admin_db).count() + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + }, + ) + + request = RequestFactory().get( + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug=saml_setup["domain"]) + + assert response.status_code == 302 + + # Verify no role was created or assigned + assert Role.objects.using(MainRouter.admin_db).count() == roles_before + assert not ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, tenant_id=tenant.id) .exists() ) - # Verify no_permissions role was NOT assigned to the user - assert not ( + # Membership is still created so the user belongs to the tenant + assert ( + Membership.objects.using(MainRouter.admin_db) + .filter(user=user, tenant=tenant) + .exists() + ) + + def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that a new read-only role is neither created nor assigned if it would remove the last MANAGE_ACCOUNT user""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + + admin_role = admin_role_fixture + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": ["brand_new_role"], + }, + ) + + request = RequestFactory().get( + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug=saml_setup["domain"]) + + assert response.status_code == 302 + + # The admin role is still assigned and the new role was not created + assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(user=user, role__name="no_permissions", tenant_id=tenant.id) + .filter(user=user, role=admin_role, tenant_id=tenant.id) + .exists() + ) + assert ( + not Role.objects.using(MainRouter.admin_db) + .filter(name="brand_new_role", tenant=tenant) .exists() ) @@ -12856,7 +14062,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -12876,16 +14084,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -12940,7 +14153,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = user request.session = {} @@ -12960,16 +14175,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -13023,7 +14243,9 @@ class TestTenantFinishACSView: ) request = RequestFactory().get( - reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + reverse( + "saml_finish_acs", kwargs={"organization_slug": saml_setup["domain"]} + ) ) request.user = non_admin_user request.session = {} @@ -13043,16 +14265,21 @@ class TestTenantFinishACSView: patch("api.models.User.objects.get") as mock_user_get, ): mock_get_app_or_404.return_value = MagicMock( - provider="saml", client_id="testtenant", name="Test App", settings={} + provider="saml", + client_id=saml_setup["domain"], + name="Test App", + settings={}, ) mock_sa_get.return_value = social_account mock_socialapp_get.return_value = MagicMock(provider_id="saml") mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) - mock_saml_config_get.return_value = MagicMock() + mock_saml_config_get.return_value = SimpleNamespace( + email_domain=saml_setup["domain"], tenant=tenant + ) mock_user_get.return_value = non_admin_user view = TenantFinishACSView.as_view() - response = view(request, organization_slug="testtenant") + response = view(request, organization_slug=saml_setup["domain"]) assert response.status_code == 302 @@ -16720,6 +17947,44 @@ class TestFindingGroupViewSet: # All fixture findings are from AWS provider assert len(response.json()["data"]) == 5 + def test_finding_groups_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + finding_groups_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 4 + + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 5 + def test_finding_groups_check_id_filter( self, authenticated_client, finding_groups_fixture ): @@ -17630,6 +18895,41 @@ class TestFindingGroupViewSet: # All providers in fixture are AWS assert len(data) == 5 + def test_finding_groups_latest_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + finding_groups_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 4 + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 5 + def test_finding_groups_latest_check_id_filter( self, authenticated_client, finding_groups_fixture ): diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 8da8d91226..678ed24772 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -243,6 +243,12 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "filter_accounts": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: + if isinstance(prowler_provider_kwargs.get("region"), str): + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "region": {prowler_provider_kwargs["region"]}, + } elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: # clouds_yaml_content, clouds_yaml_cloud and provider_id are validated # in the provider itself, so it's not needed here. diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py index e1a5d3470f..849c788366 100644 --- a/api/src/backend/api/v1/mixins.py +++ b/api/src/backend/api/v1/mixins.py @@ -1,6 +1,10 @@ +import uuid + +from django.http import QueryDict from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from api.exceptions import ( @@ -8,7 +12,7 @@ from api.exceptions import ( TaskInProgressException, TaskNotFoundException, ) -from api.models import StateChoices, Task +from api.models import Provider, StateChoices, Task from api.v1.serializers import TaskSerializer @@ -74,6 +78,162 @@ class PaginateByPkMixin: return self.get_paginated_response(serialized) +class JsonApiFilterMixin: + """Shared helpers for manually applying django-filter to JSON:API params.""" + + jsonapi_filter_replace_dots = False + + def _normalize_jsonapi_params( + self, + query_params, + exclude_keys=None, + replace_dots=None, + ): + exclude_keys = exclude_keys or set() + if replace_dots is None: + replace_dots = self.jsonapi_filter_replace_dots + + normalized = QueryDict(mutable=True) + for key, values in query_params.lists(): + normalized_key = ( + key[7:-1] if key.startswith("filter[") and key.endswith("]") else key + ) + if replace_dots: + normalized_key = normalized_key.replace(".", "__") + if normalized_key not in exclude_keys: + normalized.setlist(normalized_key, values) + return normalized + + def _apply_filterset( + self, + queryset, + filterset_class, + exclude_keys=None, + replace_dots=None, + ): + normalized_params = self._normalize_jsonapi_params( + self.request.query_params, + exclude_keys=set(exclude_keys or []), + replace_dots=replace_dots, + ) + filterset = filterset_class(normalized_params, queryset=queryset) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + return filterset.qs + + +class ProviderFilterParamsMixin(JsonApiFilterMixin): + """Shared extraction of provider filters from JSON:API query params.""" + + PROVIDER_FILTER_KEYS = frozenset( + { + "provider_id", + "provider_id__in", + "provider_type", + "provider_type__in", + "provider_groups", + "provider_groups__in", + } + ) + PROVIDER_FILTER_DOT_ALIAS_KEYS = frozenset( + { + "provider_id.in", + "provider_type.in", + "provider_groups.in", + } + ) + PROVIDER_FILTER_QUERY_KEYS = PROVIDER_FILTER_KEYS | PROVIDER_FILTER_DOT_ALIAS_KEYS + + def _csv_filter_values(self, value): + return [item.strip() for item in value.split(",") if item.strip()] + + def _validate_uuid_filter_values(self, field_name, values): + try: + for value in values: + uuid.UUID(str(value)) + except (TypeError, ValueError, AttributeError): + raise ValidationError({field_name: ["Enter a valid UUID."]}) + + def _has_provider_filters(self, include_dot_aliases=False): + provider_filter_keys = ( + self.PROVIDER_FILTER_QUERY_KEYS + if include_dot_aliases + else self.PROVIDER_FILTER_KEYS + ) + return any( + self.request.query_params.get(f"filter[{key}]") + for key in provider_filter_keys + ) + + def _extract_provider_filters_from_params( + self, + *, + validate_uuids=False, + include_dot_aliases=False, + ): + params = self.request.query_params + filters = {} + valid_provider_types = { + choice[0] for choice in Provider.ProviderChoices.choices + } + + provider_id = params.get("filter[provider_id]") + if provider_id: + if validate_uuids: + self._validate_uuid_filter_values("provider_id", [provider_id]) + filters["provider_id"] = provider_id + + provider_id_in = params.get("filter[provider_id__in]") + if include_dot_aliases: + provider_id_in = provider_id_in or params.get("filter[provider_id.in]") + if provider_id_in: + values = self._csv_filter_values(provider_id_in) + if validate_uuids: + self._validate_uuid_filter_values("provider_id__in", values) + filters["provider_id__in"] = values + + provider_type = params.get("filter[provider_type]") + if provider_type: + if provider_type not in valid_provider_types: + raise ValidationError( + {"provider_type": f"Invalid choice: {provider_type}"} + ) + filters["provider__provider"] = provider_type + + provider_type_in = params.get("filter[provider_type__in]") + if include_dot_aliases: + provider_type_in = provider_type_in or params.get( + "filter[provider_type.in]" + ) + if provider_type_in: + values = self._csv_filter_values(provider_type_in) + invalid = [value for value in values if value not in valid_provider_types] + if invalid: + raise ValidationError( + {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} + ) + filters["provider__provider__in"] = values + + provider_groups = params.get("filter[provider_groups]") + if provider_groups: + if validate_uuids: + self._validate_uuid_filter_values("provider_groups", [provider_groups]) + filters["provider__provider_groups__id"] = provider_groups + + provider_groups_in = params.get("filter[provider_groups__in]") + if include_dot_aliases: + provider_groups_in = provider_groups_in or params.get( + "filter[provider_groups.in]" + ) + if provider_groups_in: + values = self._csv_filter_values(provider_groups_in) + if validate_uuids: + self._validate_uuid_filter_values("provider_groups__in", values) + filters["provider__provider_groups__id__in"] = values + + return filters + + class TaskManagementMixin: """ Mixin to manage task status checking. diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 9c91c3201f..6b2616b289 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -30,6 +30,7 @@ from dj_rest_auth.registration.views import SocialLoginView from django.conf import settings as django_settings from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg from django.contrib.postgres.search import SearchQuery +from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from django.db.models import ( BooleanField, @@ -114,6 +115,8 @@ from api.attack_paths import get_queries_for_provider, get_query_by_id from api.attack_paths import views_helpers as attack_paths_views_helpers from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( + COMPLIANCE_WARMED, + COMPLIANCE_WARMING_STARTED, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, get_prowler_provider_compliance, @@ -122,6 +125,7 @@ from api.constants import SEVERITY_ORDER from api.db_router import MainRouter from api.db_utils import rls_transaction from api.exceptions import ( + ComplianceWarmingError, TaskFailedException, UpstreamAccessDeniedError, UpstreamAuthenticationError, @@ -224,7 +228,13 @@ from api.utils import ( validate_invitation, ) from api.uuid_utils import datetime_to_uuid7, uuid7_start -from api.v1.mixins import DisablePaginationMixin, PaginateByPkMixin, TaskManagementMixin +from api.v1.mixins import ( + DisablePaginationMixin, + JsonApiFilterMixin, + PaginateByPkMixin, + ProviderFilterParamsMixin, + TaskManagementMixin, +) from api.v1.serializers import ( AttackPathsCartographySchemaSerializer, AttackPathsCustomQueryRunRequestSerializer, @@ -757,7 +767,10 @@ class TenantFinishACSView(FinishACSView): try: check = SAMLDomainIndex.objects.get(email_domain=organization_slug) with rls_transaction(str(check.tenant_id)): - SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id)) + saml_config = SAMLConfiguration.objects.select_related("tenant").get( + tenant_id=str(check.tenant_id) + ) + tenant = saml_config.tenant social_app = SocialApp.objects.get( provider="saml", client_id=organization_slug ) @@ -777,6 +790,15 @@ class TenantFinishACSView(FinishACSView): callback_url = env.str("AUTH_URL") return redirect(f"{callback_url}?sso_saml_failed=true") + requested_domain = organization_slug.lower() + configured_domain = saml_config.email_domain.lower() + email_domain = user.email.rsplit("@", 1)[-1].lower() + if configured_domain != requested_domain or email_domain != configured_domain: + logger.error("SAML email domain does not match requested organization") + self._rollback_saml_user(request) + callback_url = env.str("AUTH_URL") + return redirect(f"{callback_url}?sso_saml_failed=true") + extra = social_account.extra_data user.first_name = ( extra.get("firstName", [""])[0] if extra.get("firstName") else "" @@ -790,67 +812,70 @@ class TenantFinishACSView(FinishACSView): user.name = "N/A" user.save() - email_domain = user.email.split("@")[-1] - tenant = ( - SAMLConfiguration.objects.using(MainRouter.admin_db) - .get(email_domain=email_domain) - .tenant - ) - + # Only remap roles when the IdP provides a userType attribute. + # Without it, the user's current roles are left untouched. role_name = ( - extra.get("userType", ["no_permissions"])[0].strip() - if extra.get("userType") - else "no_permissions" + extra.get("userType", [""])[0].strip() if extra.get("userType") else "" ) - role = ( - Role.objects.using(MainRouter.admin_db) - .filter(name=role_name, tenant=tenant) - .first() - ) - - # Only skip mapping if it would remove the last MANAGE_ACCOUNT user - remaining_manage_account_users = ( - UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(role__manage_account=True, tenant_id=tenant.id) - .exclude(user_id=user_id) - .values("user") - .distinct() - .count() - ) - user_has_manage_account = ( - UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id) - .exists() - ) - role_manage_account = role.manage_account if role else False - would_remove_last_manage_account = ( - user_has_manage_account - and remaining_manage_account_users == 0 - and not role_manage_account - ) - - if not would_remove_last_manage_account: - if role is None: - role = Role.objects.using(MainRouter.admin_db).create( - name=role_name, - tenant=tenant, - manage_users=False, - manage_account=False, - manage_billing=False, - manage_providers=False, - manage_integrations=False, - manage_scans=False, - unlimited_visibility=False, + if role_name: + with transaction.atomic(using=MainRouter.admin_db): + role = ( + Role.objects.using(MainRouter.admin_db) + .filter(name=role_name, tenant=tenant) + .first() ) - UserRoleRelationship.objects.using(MainRouter.admin_db).filter( - user=user, - tenant_id=tenant.id, - ).delete() - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=user, - role=role, - tenant_id=tenant.id, - ) + + # Only skip mapping if it would remove the last MANAGE_ACCOUNT user + remaining_manage_account_users = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role__manage_account=True, tenant_id=tenant.id) + .exclude(user_id=user_id) + .values("user") + .distinct() + .count() + ) + user_has_manage_account = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter( + role__manage_account=True, + tenant_id=tenant.id, + user_id=user_id, + ) + .exists() + ) + role_manage_account = role.manage_account if role else False + would_remove_last_manage_account = ( + user_has_manage_account + and remaining_manage_account_users == 0 + and not role_manage_account + ) + + if not would_remove_last_manage_account: + if role is None: + # Roles auto-created from userType get read-only access: + # visibility over all providers, no management permissions + role, _ = Role.objects.using(MainRouter.admin_db).get_or_create( + name=role_name, + tenant=tenant, + defaults={ + "manage_users": False, + "manage_account": False, + "manage_billing": False, + "manage_providers": False, + "manage_integrations": False, + "manage_scans": False, + "unlimited_visibility": True, + }, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).filter( + user=user, + tenant_id=tenant.id, + ).delete() + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, + role=role, + tenant_id=tenant.id, + ) membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create( user=user, tenant=tenant, @@ -1864,7 +1889,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): description=( "Download a specific compliance report as an OCSF JSON file. " "Only universal frameworks that declare an output configuration " - "produce this artifact (currently 'dora' and 'csa_ccm_4.0'); any " + "produce this artifact (currently 'dora_2022_2554' and 'csa_ccm_4.0'); any " "other framework returns 404." ), parameters=[ @@ -1873,7 +1898,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): type=str, location=OpenApiParameter.PATH, required=True, - description="The compliance report name, like 'dora'", + description="The compliance report name, like 'dora_2022_2554'", ), ], responses={ @@ -4542,15 +4567,19 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): @extend_schema_view( list=extend_schema( tags=["Compliance Overview"], - summary="List compliance overviews for a scan", - description="Retrieve an overview of all the compliance in a given scan.", + summary="List compliance overviews", + description=( + "Retrieve compliance overview data for a scan. When provider filters " + "are provided, the endpoint uses the latest completed scan for each " + "matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), ], responses={ @@ -4565,19 +4594,23 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Compliance overviews generation task failed" ), }, + filters=True, ), metadata=extend_schema( tags=["Compliance Overview"], summary="Retrieve metadata values from compliance overviews", - description="Fetch unique metadata values from a set of compliance overviews. This is useful for dynamic " - "filtering.", + description=( + "Fetch unique metadata values from compliance overviews. This is useful " + "for dynamic filtering. When provider filters are provided, metadata is " + "computed from the latest completed scan for each matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), ], responses={ @@ -4592,19 +4625,24 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Compliance overviews generation task failed" ), }, + filters=True, ), requirements=extend_schema( tags=["Compliance Overview"], - summary="List compliance requirements overview for a scan", - description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance " - "framework. This endpoint provides requirement-level details and aggregates status across regions.", + summary="List compliance requirements overview", + description=( + "Retrieve a detailed overview of compliance requirements, grouped by " + "compliance framework. This endpoint provides requirement-level details " + "and aggregates status across regions. When provider filters are provided, " + "the endpoint uses the latest completed scan for each matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), OpenApiParameter( name="filter[compliance_id]", @@ -4641,6 +4679,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): location=OpenApiParameter.QUERY, description="Compliance framework ID to get attributes for.", ), + OpenApiParameter( + name="filter[scan_id]", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Scan ID used to resolve the provider for " + "multi-provider universal frameworks (e.g. CSA CCM), so " + "the returned check IDs match the scan's provider. When omitted, " + "the first provider that declares the framework is used.", + ), ], responses={ 200: OpenApiResponse( @@ -4653,7 +4701,10 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="requirements") @method_decorator(CACHE_DECORATOR, name="attributes") -class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): +class ComplianceOverviewViewSet( + ProviderFilterParamsMixin, BaseRLSViewSet, TaskManagementMixin +): + jsonapi_filter_replace_dots = True pagination_class = ComplianceOverviewPagination queryset = ComplianceRequirementOverview.objects.all() serializer_class = ComplianceOverviewSerializer @@ -4667,28 +4718,22 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): required_permissions = [] def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return ComplianceRequirementOverview.objects.none() + role = get_role(self.request.user, self.request.tenant_id) unlimited_visibility = getattr( role, Permissions.UNLIMITED_VISIBILITY.value, False ) - if unlimited_visibility: - base_queryset = self.filter_queryset( - ComplianceRequirementOverview.objects.filter( - tenant_id=self.request.tenant_id - ) - ) - else: - providers = Provider.objects.filter( - provider_groups__in=role.provider_groups.all() - ).distinct() - base_queryset = self.filter_queryset( - ComplianceRequirementOverview.objects.filter( - tenant_id=self.request.tenant_id, scan__provider__in=providers - ) - ) + base_queryset = ComplianceRequirementOverview.objects.filter( + tenant_id=self.request.tenant_id + ) - return base_queryset + if unlimited_visibility: + return base_queryset + + return base_queryset.filter(scan__provider__in=get_providers(role)) def get_serializer_class(self): if hasattr(self, "response_serializer_class"): @@ -4726,6 +4771,72 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return summaries + def _validate_scan_selection(self, scan_id, has_provider_filters): + if scan_id and has_provider_filters: + raise ValidationError( + [ + { + "detail": "Use either filter[scan_id] or provider filters.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "invalid", + } + ] + ) + + if scan_id: + self._validate_uuid_filter_values("scan_id", [scan_id]) + return + + if has_provider_filters: + return + + raise ValidationError( + [ + { + "detail": "This query parameter is required unless a provider filter is provided.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "required", + } + ] + ) + + def _latest_scan_ids_for_provider_filters(self): + role = get_role(self.request.user, self.request.tenant_id) + scans = Scan.all_objects.filter( + tenant_id=self.request.tenant_id, + state=StateChoices.COMPLETED, + ) + + if not getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scans = scans.filter(provider__in=get_providers(role)) + + provider_filters = self._extract_provider_filters_from_params( + validate_uuids=True, + include_dot_aliases=True, + ) + if provider_filters: + scans = scans.filter(**provider_filters) + + return list( + scans.order_by("provider_id", "-inserted_at") + .distinct("provider_id") + .values_list("id", flat=True) + ) + + def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None): + if latest_scan_ids is None: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids) + # Provider filters stay on the filterset for OpenAPI docs, but runtime + # filtering happens on Scan first so compliance queries use scan IDs. + return self._apply_filterset( + queryset, + self.filterset_class, + exclude_keys=self.PROVIDER_FILTER_KEYS | {"scan_id"}, + ) + def _get_compliance_template(self, *, provider=None, scan_id=None): """Return the compliance template for the given provider or scan.""" if provider is None and scan_id is not None: @@ -4841,6 +4952,36 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + def _task_response_for_latest_provider_scans(self, latest_scan_ids): + for scan_id in latest_scan_ids: + task_response = self._task_response_if_running(str(scan_id)) + if task_response: + return task_response + return None + + def _latest_provider_scan_ids_without_data(self, latest_scan_ids): + data_presence_queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids) + scan_ids_with_data = { + str(scan_id) + for scan_id in data_presence_queryset.values_list( + "scan_id", flat=True + ).distinct() + } + return [ + scan_id + for scan_id in latest_scan_ids + if str(scan_id) not in scan_ids_with_data + ] + + def _task_response_for_latest_provider_scans_without_data( + self, + latest_scan_ids, + ): + scan_ids_to_check = self._latest_provider_scan_ids_without_data( + latest_scan_ids, + ) + return self._task_response_for_latest_provider_scans(scan_ids_to_check) + def _list_with_region_filter(self, scan_id, region_filter): """ Fall back to detailed ComplianceRequirementOverview query when region filter is applied. @@ -4881,8 +5022,25 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return Response(data) + def _list_with_latest_provider_filters(self): + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self._filtered_queryset_for_latest_provider_scans(latest_scan_ids) + data = self._aggregate_compliance_overview(queryset) + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + if task_response: + return task_response + + return Response(data) + def list(self, request, *args, **kwargs): scan_id = request.query_params.get("filter[scan_id]") + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) + self._validate_scan_selection(scan_id, has_provider_filters) + + if has_provider_filters: + return self._list_with_latest_provider_filters() # Specific scan requested - use optimized summaries with region support region_filter = request.query_params.get( @@ -4928,33 +5086,34 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="metadata") def metadata(self, request): scan_id = request.query_params.get("filter[scan_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) + self._validate_scan_selection(scan_id, has_provider_filters) + + latest_scan_ids = None + if has_provider_filters: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self._filtered_queryset_for_latest_provider_scans( + latest_scan_ids ) + else: + queryset = self._apply_filterset(self.get_queryset(), self.filterset_class) + regions = list( - self.get_queryset() - .filter(scan_id=scan_id) - .values_list("region", flat=True) - .order_by("region") - .distinct() + queryset.values_list("region", flat=True).order_by("region").distinct() ) result = {"regions": regions} - if regions: - serializer = self.get_serializer(data=result) - serializer.is_valid(raise_exception=True) - return Response(serializer.data, status=status.HTTP_200_OK) + task_response = None + if has_provider_filters: + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + elif not regions: + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response - task_response = self._task_response_if_running(scan_id) - if task_response: + if has_provider_filters and task_response: return task_response serializer = self.get_serializer(data=result) @@ -4964,19 +5123,10 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="requirements") def requirements(self, request): scan_id = request.query_params.get("filter[scan_id]") + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) compliance_id = request.query_params.get("filter[compliance_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] - ) + self._validate_scan_selection(scan_id, has_provider_filters) if not compliance_id: raise ValidationError( @@ -4989,7 +5139,16 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): } ] ) - filtered_queryset = self.filter_queryset(self.get_queryset()) + latest_scan_ids = None + if has_provider_filters: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + filtered_queryset = self._filtered_queryset_for_latest_provider_scans( + latest_scan_ids + ) + else: + filtered_queryset = self._apply_filterset( + self.get_queryset(), self.filterset_class + ) all_requirements = filtered_queryset.values( "requirement_id", @@ -5048,17 +5207,33 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): requirements_summary, many=True ) + task_response = None + if has_provider_filters: + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + elif not requirements_summary: + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response + + if has_provider_filters and task_response: + return task_response + if requirements_summary: return Response(serializer.data, status=status.HTTP_200_OK) - task_response = self._task_response_if_running(scan_id) - if task_response: - return task_response - return Response(serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=["get"], url_name="attributes") def attributes(self, request): + # While the background warm-up is in progress, refuse immediately + # instead of falling through to the slow cold load on the request + # thread (which would trip the Gunicorn worker timeout). `is_set()` is + # a non-blocking flag read, so this never touches the loader. + if COMPLIANCE_WARMING_STARTED.is_set() and not COMPLIANCE_WARMED.is_set(): + raise ComplianceWarmingError() + compliance_id = request.query_params.get("filter[compliance_id]") if not compliance_id: raise ValidationError( @@ -5074,7 +5249,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): provider_type = None - # If we couldn't determine from database, try each provider type + # When a scan is provided, resolve the provider from it. Multi-provider + # universal frameworks (e.g. CSA CCM) share a single compliance_id + # across providers but expose different checks per provider, so the + # metadata (and therefore the check IDs the UI uses to fetch findings) + # must be returned for the scan's provider. Without this, the endpoint + # falls back to the first provider that declares the framework and + # returns its check IDs, leaving azure/gcp/... requirements with no + # matching findings. + scan_id = request.query_params.get("filter[scan_id]") + if "filter[scan_id]" in request.query_params: + # An explicit scan_id is authoritative: fail closed instead of + # falling back to another provider. Otherwise an invalid, empty + # (filter[scan_id]=) or inaccessible scan would silently return the + # first provider's check IDs, recreating the multi-provider mismatch + # this endpoint fixes. + if not scan_id: + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + # Tenant isolation is already enforced by Postgres RLS on the + # connection (see BaseRLSViewSet). Scope the lookup by provider + # group as well so a user with limited visibility can't resolve + # another provider's scan and read its compliance metadata, mirroring + # the RBAC scoping get_queryset() applies to the rest of the ViewSet. + role = get_role(request.user, request.tenant_id) + if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id) + else: + scan_queryset = Scan.objects.filter(provider__in=get_providers(role)) + + try: + scan = scan_queryset.select_related("provider").get(id=scan_id) + except (Scan.DoesNotExist, DjangoValidationError, ValueError): + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + provider_type = scan.provider.provider + if compliance_id not in get_compliance_frameworks(provider_type): + raise NotFound( + detail=( + f"Compliance framework '{compliance_id}' is not " + f"available for scan '{scan_id}'." + ) + ) + + # Fall back to the first provider that declares the framework. Keeps the + # endpoint working for provider-agnostic callers that omit the scan. if not provider_type: for pt in Provider.ProviderChoices.values: if compliance_id in get_compliance_frameworks(pt): @@ -5242,7 +5461,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): ), ) @method_decorator(CACHE_DECORATOR, name="list") -class OverviewViewSet(BaseRLSViewSet): +class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet): queryset = ScanSummary.objects.all() http_method_names = ["get"] ordering = ["-inserted_at"] @@ -5359,18 +5578,6 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id=tenant_id, scan_id__in=latest_scan_ids ) - def _normalize_jsonapi_params(self, query_params, exclude_keys=None): - """Convert JSON:API filter params (filter[X]) to flat params (X).""" - exclude_keys = exclude_keys or set() - normalized = QueryDict(mutable=True) - for key, values in query_params.lists(): - normalized_key = ( - key[7:-1] if key.startswith("filter[") and key.endswith("]") else key - ) - if normalized_key not in exclude_keys: - normalized.setlist(normalized_key, values) - return normalized - def _ensure_allowed_providers(self): """Populate allowed providers for RBAC-aware queries once per request.""" if getattr(self, "_providers_initialized", False): @@ -5390,15 +5597,6 @@ class OverviewViewSet(BaseRLSViewSet): return queryset.filter(**provider_filter) return queryset - def _apply_filterset(self, queryset, filterset_class, exclude_keys=None): - normalized_params = self._normalize_jsonapi_params( - self.request.query_params, exclude_keys=set(exclude_keys or []) - ) - filterset = filterset_class(normalized_params, queryset=queryset) - if not filterset.is_valid(): - raise ValidationError(filterset.errors) - return filterset.qs - def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None): provider_filter = self._get_provider_filter() queryset = Scan.all_objects.filter( @@ -5412,40 +5610,6 @@ class OverviewViewSet(BaseRLSViewSet): .values_list("id", flat=True) ) - def _extract_provider_filters_from_params(self): - """Extract and validate provider filters from query params.""" - params = self.request.query_params - filters = {} - valid_provider_types = {c[0] for c in Provider.ProviderChoices.choices} - - provider_id = params.get("filter[provider_id]") - if provider_id: - filters["provider_id"] = provider_id - - provider_id_in = params.get("filter[provider_id__in]") - if provider_id_in: - filters["provider_id__in"] = provider_id_in.split(",") - - provider_type = params.get("filter[provider_type]") - if provider_type: - if provider_type not in valid_provider_types: - raise ValidationError( - {"provider_type": f"Invalid choice: {provider_type}"} - ) - filters["provider__provider"] = provider_type - - provider_type_in = params.get("filter[provider_type__in]") - if provider_type_in: - types = provider_type_in.split(",") - invalid = [t for t in types if t not in valid_provider_types] - if invalid: - raise ValidationError( - {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} - ) - filters["provider__provider__in"] = types - - return filters - @action(detail=False, methods=["get"], url_name="providers") def providers(self, request): tenant_id = self.request.tenant_id @@ -5516,15 +5680,11 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id = self.request.tenant_id providers_qs = Provider.objects.filter(tenant_id=tenant_id) + self._ensure_allowed_providers() if hasattr(self, "allowed_providers"): - allowed_ids = list(self.allowed_providers.values_list("id", flat=True)) - if not allowed_ids: - overview = [] - return Response( - self.get_serializer(overview, many=True).data, - status=status.HTTP_200_OK, - ) - providers_qs = providers_qs.filter(id__in=allowed_ids) + providers_qs = providers_qs.filter( + id__in=self.allowed_providers.values("id") + ) overview = ( providers_qs.values("provider") @@ -5740,29 +5900,41 @@ class OverviewViewSet(BaseRLSViewSet): description="Retrieve a specific snapshot by ID. If not provided, returns latest snapshots.", ), OpenApiParameter( - name="provider_id", + name="filter[provider_id]", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, description="Filter by specific provider ID", ), OpenApiParameter( - name="provider_id__in", + name="filter[provider_id__in]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by multiple provider IDs (comma-separated UUIDs)", ), OpenApiParameter( - name="provider_type", + name="filter[provider_type]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by provider type (aws, azure, gcp, etc.)", ), OpenApiParameter( - name="provider_type__in", + name="filter[provider_type__in]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by multiple provider types (comma-separated)", ), + OpenApiParameter( + name="filter[provider_groups]", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Filter by provider group ID", + ), + OpenApiParameter( + name="filter[provider_groups__in]", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by multiple provider group IDs (comma-separated UUIDs)", + ), ], ) @action(detail=False, methods=["get"], url_name="threatscore") @@ -6104,6 +6276,8 @@ class OverviewViewSet(BaseRLSViewSet): "provider_id__in", "provider_type", "provider_type__in", + "provider_groups", + "provider_groups__in", } filtered_queryset = self._apply_filterset( base_queryset, CategoryOverviewFilter, exclude_keys=provider_filter_keys @@ -6173,6 +6347,8 @@ class OverviewViewSet(BaseRLSViewSet): "provider_id__in", "provider_type", "provider_type__in", + "provider_groups", + "provider_groups__in", } filtered_queryset = self._apply_filterset( base_queryset, @@ -7223,7 +7399,7 @@ SEVERITY_ORDER_REVERSE = {v: k for k, v in SEVERITY_ORDER.items()} ), retrieve=extend_schema(exclude=True), ) -class FindingGroupViewSet(BaseRLSViewSet): +class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet): """ ViewSet for Finding Groups - aggregates findings by check_id. @@ -7239,6 +7415,7 @@ class FindingGroupViewSet(BaseRLSViewSet): queryset = FindingGroupDailySummary.objects.all() serializer_class = FindingGroupSerializer filterset_class = FindingGroupFilter + jsonapi_filter_replace_dots = True filter_backends = [ jsonapi_filters.QueryParameterValidationFilter, jsonapi_filters.OrderingFilter, @@ -7289,18 +7466,6 @@ class FindingGroupViewSet(BaseRLSViewSet): return queryset - def _normalize_jsonapi_params(self, query_params): - """Convert JSON:API filter params (filter[X]) to flat params (X).""" - normalized = QueryDict(mutable=True) - for key, values in query_params.lists(): - normalized_key = ( - key[7:-1] if key.startswith("filter[") and key.endswith("]") else key - ) - # Convert JSON:API dot notation to Django double underscore - normalized_key = normalized_key.replace(".", "__") - normalized.setlist(normalized_key, values) - return normalized - @extend_schema(exclude=True) def retrieve(self, request, *args, **kwargs): raise MethodNotAllowed(method="GET") @@ -8419,9 +8584,10 @@ class FindingGroupViewSet(BaseRLSViewSet): This endpoint returns finding groups without requiring date filters, automatically using the latest available data per check_id. - All other filters (provider_id, provider_type, check_id) are still supported. + Provider, provider group, check, and computed filters are still supported. """, tags=["Finding Groups"], + filters=True, ) @action(detail=False, methods=["get"], url_name="latest") def latest(self, request): diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 38cf047ac2..31a9537b5f 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -3,6 +3,7 @@ from datetime import timedelta from config.custom_logging import LOGGING # noqa from config.env import BASE_DIR, env # noqa from config.settings.celery import * # noqa +from config.settings.eventstream import * # noqa from config.settings.partitions import * # noqa from config.settings.sentry import * # noqa from config.settings.social_login import * # noqa @@ -44,9 +45,11 @@ INSTALLED_APPS = [ "dj_rest_auth.registration", "rest_framework.authtoken", "drf_simple_apikey", + "django_eventstream", ] MIDDLEWARE = [ + "api.middleware.CloseDBConnectionsMiddleware", "django_guid.middleware.guid_middleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -136,6 +139,7 @@ SPECTACULAR_SETTINGS = { } WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" DJANGO_GUID = { "GUID_HEADER_NAME": "Transaction-ID", diff --git a/api/src/backend/config/guniconf.py b/api/src/backend/config/guniconf.py index 536fd97abb..6f85a9e1bc 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -1,6 +1,9 @@ import logging import multiprocessing import os +import threading + +from uvicorn_worker import UvicornWorker from config.env import env @@ -11,18 +14,45 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production") import django # noqa: E402 django.setup() + +from api.compliance import warm_compliance_caches # noqa: E402 from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402 from config.custom_logging import BackendLogger # noqa: E402 BIND_ADDRESS = env("DJANGO_BIND_ADDRESS", default="127.0.0.1") PORT = env("DJANGO_PORT", default=8080) + +class ProwlerUvicornWorker(UvicornWorker): + CONFIG_KWARGS = { + # Keep-alive idle timeout. Must exceed the load balancer idle timeout. + "timeout_keep_alive": env.int("GUNICORN_KEEPALIVE", default=75), + "loop": "uvloop", + "lifespan": "off", # Django ASGIHandler doesn't handle lifespan scopes + } + + +# Required so SSE endpoints can keep the event loop alive while waiting for events +worker_class = env( + "DJANGO_WORKER_CLASS", + default="config.guniconf.ProwlerUvicornWorker", +) + # Server settings bind = f"{BIND_ADDRESS}:{PORT}" workers = env.int("DJANGO_WORKERS", default=multiprocessing.cpu_count() * 2 + 1) reload = DEBUG +# Preload the application before forking workers in production: the app is +# imported once in the master and workers fork from it. In development, disable +# preload so the server restarts on code changes. +preload_app = not DEBUG + +# Worker timeout in seconds. Increased from the default 30s to handle requests +# that may take longer, such as complex API operations. +timeout = env.int("GUNICORN_TIMEOUT", default=120) + # Logging logconfig_dict = DJANGO_LOGGERS gunicorn_logger = logging.getLogger(BackendLogger.GUNICORN) @@ -41,3 +71,26 @@ def on_reload(_): def when_ready(_): gunicorn_logger.info("Gunicorn server is ready") + + +def _warm_compliance_caches_in_background(): + """Warm compliance caches off the request path and log the outcome.""" + failed = warm_compliance_caches() + if failed: + gunicorn_logger.warning("Compliance caches warmed (skipped: %s)", failed) + else: + gunicorn_logger.info("Compliance caches warmed") + + +def post_fork(_server, worker): + """Warm compliance caches after each worker fork. + + Warm compliance caches in a background thread so the worker becomes ready + immediately. A request for a not-yet-warmed provider lazily loads just that + provider, which stays well under the worker timeout. + """ + threading.Thread( + target=_warm_compliance_caches_in_background, + name="warm-compliance-caches", + daemon=True, + ).start() diff --git a/api/src/backend/config/settings/celery.py b/api/src/backend/config/settings/celery.py index b7030ebea4..b7105a548c 100644 --- a/api/src/backend/config/settings/celery.py +++ b/api/src/backend/config/settings/celery.py @@ -53,3 +53,8 @@ CELERY_TASK_TRACK_STARTED = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True CELERY_DEADLOCK_ATTEMPTS = env.int("DJANGO_CELERY_DEADLOCK_ATTEMPTS", default=5) + +# Opt-in override for Celery's prefork pool size. When unset, Celery falls back +# to its default (os.cpu_count()). +if "DJANGO_CELERY_WORKER_CONCURRENCY" in env.ENVIRON: + CELERY_WORKER_CONCURRENCY = env.int("DJANGO_CELERY_WORKER_CONCURRENCY") diff --git a/api/src/backend/config/settings/eventstream.py b/api/src/backend/config/settings/eventstream.py new file mode 100644 index 0000000000..470062050b --- /dev/null +++ b/api/src/backend/config/settings/eventstream.py @@ -0,0 +1,41 @@ +"""Server-Sent Events (SSE) configuration. + +Wires django-eventstream into the platform: Valkey Pub/Sub backend on a +dedicated DB (separate from the Celery broker), the platform channel +manager, and headers that match the existing CORS allowlist. +""" + +from config.env import env +from config.settings.celery import ( + VALKEY_HOST, + VALKEY_PASSWORD, + VALKEY_PORT, + VALKEY_SCHEME, + VALKEY_USERNAME, +) + +# Dedicated Valkey DB for the SSE Pub/Sub bus. Kept distinct from the +# Celery broker DB so a noisy broker can't shoulder out streaming +# traffic on the same keyspace. +EVENTSTREAM_VALKEY_DB = env.int("EVENTSTREAM_VALKEY_DB", default=2) + +EVENTSTREAM_REDIS: dict = { + "host": VALKEY_HOST, + "port": int(VALKEY_PORT), + "db": EVENTSTREAM_VALKEY_DB, +} +if VALKEY_PASSWORD: + EVENTSTREAM_REDIS["password"] = VALKEY_PASSWORD +if VALKEY_USERNAME: + EVENTSTREAM_REDIS["username"] = VALKEY_USERNAME +if VALKEY_SCHEME == "rediss": + EVENTSTREAM_REDIS["ssl"] = True + +# Platform channel manager — performs the per-feature authorization and +# rewrites the placeholder channel from the URL into the canonical +# tenant-scoped channel name. See ``api.sse.channelmanager``. +EVENTSTREAM_CHANNELMANAGER_CLASS = "api.sse.channelmanager.SSEChannelManager" + +# Headers a browser EventSource may legitimately send. Keep tight; the +# stream itself reads no body, so no permissive defaults. +EVENTSTREAM_ALLOW_HEADERS = "Cache-Control, Last-Event-ID" diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index 580821f7b2..1f449f782e 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -76,6 +76,8 @@ IGNORED_EXCEPTIONS = [ # PowerShell Errors in User Authentication "Microsoft Teams User Auth connection failed: Please check your permissions and try again.", "Exchange Online User Auth connection failed: Please check your permissions and try again.", + # ASGI: Client disconnected before the response finished (health-check probes on /health/live) + "RequestAborted", ] diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 185acb7f99..96bd03ee6c 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -58,6 +58,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( ProwlerThreatScoreAlibaba, ) @@ -152,6 +155,9 @@ COMPLIANCE_CLASS_MAP = { ProwlerThreatScoreAlibaba, ), ], + "okta": [ + (lambda name: name.startswith("okta_idaas_stig"), OktaIDaaSSTIG), + ], } diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index db5018db90..f6ae3e6402 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -5,6 +5,7 @@ import re import time import uuid from collections import defaultdict +from collections.abc import Iterable from datetime import datetime, timezone from typing import Any @@ -22,7 +23,6 @@ from django.db.models import ( Max, Min, OuterRef, - Prefetch, Q, Sum, When, @@ -269,6 +269,7 @@ def _store_resources( provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -276,6 +277,7 @@ def _store_resources( ) if not created: + resource_instance.name = finding.resource_name resource_instance.region = finding.region resource_instance.service = finding.service_name resource_instance.type = finding.resource_type @@ -355,68 +357,71 @@ def _copy_compliance_requirement_rows( def _persist_compliance_requirement_rows( - tenant_id: str, rows: list[dict[str, Any]], batch_size: int = 10000 -) -> None: + tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000 +) -> int: """Persist compliance requirement rows using batched COPY with ORM fallback. - Splits large row sets into batches to reduce lock duration and improve concurrency. + ``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size`` + rows instead of the full set. A batch that fails COPY falls back to an ORM + ``bulk_create`` of just that batch. Args: tenant_id: Target tenant UUID. - rows: Precomputed row dictionaries that reflect the compliance - overview state for a scan. + rows: Iterable of row dictionaries reflecting the compliance overview + state for a scan. batch_size: Number of rows per COPY batch (default: 10000). + + Returns: + int: total number of rows persisted. """ - if not rows: - return - - total_rows = len(rows) - total_batches = (total_rows + batch_size - 1) // batch_size - - try: - # Process rows in batches to reduce lock duration - for batch_num in range(total_batches): - start_idx = batch_num * batch_size - end_idx = min(start_idx + batch_size, total_rows) - batch = rows[start_idx:end_idx] + total_rows = 0 + batch_num = 0 + for batch, _is_last in batched(rows, batch_size): + if not batch: + continue + batch_num += 1 + try: _copy_compliance_requirement_rows(tenant_id, batch) + except Exception as error: + logger.exception( + f"COPY bulk insert for compliance requirements batch {batch_num} " + "failed; falling back to ORM bulk_create for this batch", + exc_info=error, + ) + fallback_objects = [ + ComplianceRequirementOverview( + id=row["id"], + tenant_id=row["tenant_id"], + inserted_at=row["inserted_at"], + compliance_id=row["compliance_id"], + framework=row["framework"], + version=row["version"], + description=row["description"], + region=row["region"], + requirement_id=row["requirement_id"], + requirement_status=row["requirement_status"], + passed_checks=row["passed_checks"], + failed_checks=row["failed_checks"], + total_checks=row["total_checks"], + passed_findings=row.get("passed_findings", 0), + total_findings=row.get("total_findings", 0), + scan_id=row["scan_id"], + ) + for row in batch + ] + with rls_transaction(tenant_id): + ComplianceRequirementOverview.objects.bulk_create( + fallback_objects, batch_size=500 + ) - logger.info( - f"Compliance COPY batch {batch_num + 1}/{total_batches}: " - f"inserted {len(batch)} rows ({start_idx + len(batch)}/{total_rows} total)" - ) - except Exception as error: - logger.exception( - "COPY bulk insert for compliance requirements failed; falling back to ORM bulk_create", - exc_info=error, + total_rows += len(batch) + logger.info( + f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows " + f"({total_rows} total)" ) - # Fallback: use ORM bulk_create for all remaining rows - fallback_objects = [ - ComplianceRequirementOverview( - id=row["id"], - tenant_id=row["tenant_id"], - inserted_at=row["inserted_at"], - compliance_id=row["compliance_id"], - framework=row["framework"], - version=row["version"], - description=row["description"], - region=row["region"], - requirement_id=row["requirement_id"], - requirement_status=row["requirement_status"], - passed_checks=row["passed_checks"], - failed_checks=row["failed_checks"], - total_checks=row["total_checks"], - passed_findings=row.get("passed_findings", 0), - total_findings=row.get("total_findings", 0), - scan_id=row["scan_id"], - ) - for row in rows - ] - with rls_transaction(tenant_id): - ComplianceRequirementOverview.objects.bulk_create( - fallback_objects, batch_size=500 - ) + + return total_rows def _create_compliance_summaries( @@ -704,6 +709,12 @@ def _process_finding_micro_batch( if finding.region and resource_instance.region != finding.region: resource_instance.region = finding.region updated = True + if ( + finding.resource_name + and resource_instance.name != finding.resource_name + ): + resource_instance.name = finding.resource_name + updated = True if resource_instance.service != finding.service_name: resource_instance.service = finding.service_name updated = True @@ -945,6 +956,7 @@ def _process_finding_micro_batch( Resource.objects.bulk_update( resources_to_bulk_update, [ + "name", "metadata", "details", "partition", @@ -1436,9 +1448,13 @@ def _aggregate_findings_by_region( tenant_id: str, scan_id: str, modeled_threatscore_compliance_id: str ) -> tuple[dict, dict]: """ - Aggregate findings by region using optimized ORM queries. + Aggregate findings by region using streaming, column-scoped ORM reads. - Replaces nested Python loops with efficient queries and aggregation. + Reads only the consumed columns as tuples via ``values_list`` and streams + them with ``.iterator()``, using the denormalized ``resource_regions`` array + instead of ``prefetch_related("resources")``. ``resource_regions`` mirrors the + regions of a finding's related resources, so it yields the same per-region + tally without joining the resource table. Args: tenant_id: Tenant UUID @@ -1450,12 +1466,12 @@ def _aggregate_findings_by_region( - check_status_by_region: {region: {check_id: status}} - findings_count_by_compliance: {region: {normalized_id: {requirement_id: {total, pass}}}} """ - check_status_by_region = {} - findings_count_by_compliance = {} + check_status_by_region: dict = {} + findings_count_by_compliance: dict = {} + + normalized_id = re.sub(r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower()) with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): - # Fetch only PASS/FAIL findings (optimized query reduces data transfer) - # Other statuses are not needed for check_status or ThreatScore calculation findings = ( Finding.all_objects.filter( tenant_id=tenant_id, @@ -1463,42 +1479,28 @@ def _aggregate_findings_by_region( muted=False, status__in=["PASS", "FAIL"], ) - .only("id", "check_id", "status", "compliance") - .prefetch_related( - Prefetch( - "resources", - queryset=Resource.objects.only("id", "region"), - to_attr="small_resources", - ) + .values_list("check_id", "status", "resource_regions", "compliance") + .iterator(chunk_size=DJANGO_FINDINGS_BATCH_SIZE) + ) + + for check_id, status, resource_regions, compliance in findings: + threatscore_requirements = (compliance or {}).get( + modeled_threatscore_compliance_id ) - ) - # Process findings in a single pass (more efficient than original nested loops) - normalized_id = re.sub( - r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() - ) - - for finding in findings: - status = finding.status - - for resource in finding.small_resources: - region = resource.region - - # Aggregate check status by region - current_status = check_status_by_region.setdefault(region, {}) + for region in resource_regions or (): # Priority: FAIL > any other status - if current_status.get(finding.check_id) != "FAIL": - current_status[finding.check_id] = status + current_status = check_status_by_region.setdefault(region, {}) + if current_status.get(check_id) != "FAIL": + current_status[check_id] = status # Aggregate ThreatScore compliance counts - if modeled_threatscore_compliance_id in (finding.compliance or {}): + if threatscore_requirements: compliance_key = findings_count_by_compliance.setdefault( region, {} ).setdefault(normalized_id, {}) - for requirement_id in finding.compliance[ - modeled_threatscore_compliance_id - ]: + for requirement_id in threatscore_requirements: requirement_stats = compliance_key.setdefault( requirement_id, {"total": 0, "pass": 0} ) @@ -1545,8 +1547,8 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): (compliance_id, requirement_id) ) - compliance_requirement_rows: list[dict[str, Any]] = [] regions = [] + requirements_created = 0 requirement_statuses = defaultdict( lambda: {"fail_count": 0, "pass_count": 0, "total_count": 0} ) @@ -1586,44 +1588,93 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): else: requirement_stats["failed_checks"] += 1 - # Prepare compliance requirement rows and compute summaries in single pass utc_datetime_now = datetime.now(tz=timezone.utc) - - # Pre-compute shared strings (optimization: reduces string conversions) tenant_id_str = str(tenant_id) scan_id_str = str(scan_instance.id) - for region in regions: - region_stats = region_requirement_stats.get(region, {}) - for compliance_id, compliance in compliance_template.items(): - modeled_compliance_id = _normalized_compliance_key( - compliance["framework"], compliance["version"] + # Per-framework constants that don't depend on the region. + compliance_plan = [] + for compliance_id, compliance in compliance_template.items(): + modeled_compliance_id = _normalized_compliance_key( + compliance["framework"], compliance["version"] + ) + framework = compliance["framework"] + version = compliance["version"] or "" + requirements = [ + ( + requirement_id, + requirement.get("description") or "", + len(requirement["checks"]), ) - compliance_stats = region_stats.get(compliance_id, {}) - # Create an overview record for each requirement within each compliance framework for requirement_id, requirement in compliance[ "requirements" - ].items(): - stats = compliance_stats.get(requirement_id) - passed_checks = stats["passed_checks"] if stats else 0 - failed_checks = stats["failed_checks"] if stats else 0 - total_checks = len(requirement["checks"]) - if total_checks == 0: - requirement_status = "MANUAL" - elif failed_checks > 0: - requirement_status = "FAIL" - else: - requirement_status = "PASS" + ].items() + ] + compliance_plan.append( + ( + compliance_id, + framework, + version, + modeled_compliance_id, + requirements, + ) + ) - compliance_requirement_rows.append( - { + # Yield rows lazily (consumed batch-by-batch by COPY) so peak memory + # stays bounded; tally requirement_statuses in the same pass. + def _iter_compliance_requirement_rows(): + for region in regions: + region_stats = region_requirement_stats.get(region, {}) + region_findings = findings_count_by_compliance.get(region, {}) + for ( + compliance_id, + framework, + version, + modeled_compliance_id, + requirements, + ) in compliance_plan: + compliance_stats = region_stats.get(compliance_id, {}) + compliance_findings = region_findings.get( + modeled_compliance_id, {} + ) + for requirement_id, description, total_checks in requirements: + stats = compliance_stats.get(requirement_id) + if stats: + passed_checks = stats["passed_checks"] + failed_checks = stats["failed_checks"] + else: + passed_checks = 0 + failed_checks = 0 + if total_checks == 0: + requirement_status = "MANUAL" + elif failed_checks > 0: + requirement_status = "FAIL" + else: + requirement_status = "PASS" + + finding_counts = compliance_findings.get(requirement_id) + if finding_counts: + passed_findings = finding_counts.get("pass", 0) + total_findings = finding_counts.get("total", 0) + else: + passed_findings = 0 + total_findings = 0 + + key = (compliance_id, requirement_id) + requirement_statuses[key]["total_count"] += 1 + if requirement_status == "FAIL": + requirement_statuses[key]["fail_count"] += 1 + elif requirement_status == "PASS": + requirement_statuses[key]["pass_count"] += 1 + + yield { "id": uuid.uuid4(), "tenant_id": tenant_id_str, "inserted_at": utc_datetime_now, "compliance_id": compliance_id, - "framework": compliance["framework"], - "version": compliance["version"] or "", - "description": requirement.get("description") or "", + "framework": framework, + "version": version, + "description": description, "region": region, "requirement_id": requirement_id, "requirement_status": requirement_status, @@ -1631,41 +1682,23 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): "failed_checks": failed_checks, "total_checks": total_checks, "scan_id": scan_id_str, - "passed_findings": findings_count_by_compliance.get( - region, {} - ) - .get(modeled_compliance_id, {}) - .get(requirement_id, {}) - .get("pass", 0), - "total_findings": findings_count_by_compliance.get( - region, {} - ) - .get(modeled_compliance_id, {}) - .get(requirement_id, {}) - .get("total", 0), + "passed_findings": passed_findings, + "total_findings": total_findings, } - ) - # Update summary tracking (single-pass optimization) - key = (compliance_id, requirement_id) - requirement_statuses[key]["total_count"] += 1 - if requirement_status == "FAIL": - requirement_statuses[key]["fail_count"] += 1 - elif requirement_status == "PASS": - requirement_statuses[key]["pass_count"] += 1 - - # Idempotent re-run: COPY can't ON CONFLICT, so clear this scan's rows first. + # Idempotent re-run: clear this scan's rows before re-inserting. with rls_transaction(tenant_id): ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete() - # Bulk create requirement records using PostgreSQL COPY - _persist_compliance_requirement_rows(tenant_id, compliance_requirement_rows) + requirements_created = _persist_compliance_requirement_rows( + tenant_id, _iter_compliance_requirement_rows() + ) # Create pre-aggregated summaries for fast compliance overview lookups _create_compliance_summaries(tenant_id, scan_id, requirement_statuses) return { - "requirements_created": len(compliance_requirement_rows), + "requirements_created": requirements_created, "regions_processed": list(regions), "compliance_frameworks": ( list(compliance_template.keys()) if regions else [] diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index e617339973..e1e7100ae6 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -560,7 +560,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): # Per-framework exporters in `COMPLIANCE_CLASS_MAP` consume the legacy bulk. frameworks_bulk = Compliance.get_bulk(provider_type) - # Universal-only frameworks (top-level JSONs like `dora.json`) are emitted + # Universal-only frameworks (top-level JSONs like `dora_2022_2554.json`) are emitted # via `process_universal_compliance_frameworks` below. universal_bulk = get_prowler_provider_compliance(provider_type) universal_only_names = { @@ -650,7 +650,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): writer.batch_write_data_to_file(**extra) writer._data.clear() - # Universal-only frameworks (e.g. `dora.json`). + # Universal-only frameworks (e.g. `dora_2022_2554.json`). if universal_only_names: process_universal_compliance_frameworks( input_compliance_frameworks=universal_only_names, diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 8d3d0be93a..130d8ae118 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -315,6 +315,7 @@ class TestPerformScan: provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -348,6 +349,7 @@ class TestPerformScan: resource_instance = MagicMock() resource_instance.uid = finding.resource_uid + resource_instance.name = "old_name" resource_instance.region = "us-west-1" resource_instance.service = "old_service" resource_instance.type = "old_type" @@ -366,6 +368,7 @@ class TestPerformScan: provider=provider_instance, uid=finding.resource_uid, defaults={ + "name": finding.resource_name, "region": finding.region, "service": finding.service_name, "type": finding.resource_type, @@ -373,6 +376,7 @@ class TestPerformScan: ) # Check that resource fields were updated + assert resource_instance.name == finding.resource_name assert resource_instance.region == finding.region assert resource_instance.service == finding.service_name assert resource_instance.type == finding.resource_type @@ -1565,6 +1569,75 @@ class TestProcessFindingMicroBatch: assert resource_cache[finding.resource_uid].service == finding.service_name assert tag_cache.keys() == {("team", "devsec")} + def test_process_finding_micro_batch_refreshes_empty_resource_name( + self, tenants_fixture, scans_fixture + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = scan.provider + + # Old resource stored before names were persisted: empty name. + existing_resource = Resource.objects.create( + tenant_id=tenant.id, + provider=provider, + uid="arn:aws:s3:::my-bucket", + name="", + region="us-east-1", + service="s3", + type="bucket", + ) + + finding = FakeFinding( + uid="finding-empty-name", + status=StatusChoices.PASS, + status_extended="passing", + severity=Severity.low, + check_id="s3_bucket_public_access", + resource_uid=existing_resource.uid, + resource_name="my-bucket", + region="us-east-1", + service_name="s3", + resource_type="bucket", + partition="aws", + raw={"status": "PASS"}, + metadata={"source": "prowler"}, + ) + + resource_cache = {existing_resource.uid: existing_resource} + tag_cache = {} + last_status_cache = {} + resource_failed_findings_cache = {existing_resource.uid: 0} + unique_resources: set[tuple[str, str]] = set() + scan_resource_cache: set[tuple[str, str, str, str]] = set() + mute_rules_cache = {} + scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {} + scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {} + group_resources_cache: dict[str, set] = {} + + with ( + patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction), + patch("api.db_utils.rls_transaction", new=noop_rls_transaction), + ): + _process_finding_micro_batch( + str(tenant.id), + [finding], + scan, + provider, + resource_cache, + tag_cache, + last_status_cache, + resource_failed_findings_cache, + unique_resources, + scan_resource_cache, + mute_rules_cache, + scan_categories_cache, + scan_resource_groups_cache, + group_resources_cache, + ) + + existing_resource.refresh_from_db() + assert existing_resource.name == finding.resource_name + def test_process_finding_micro_batch_skips_long_uid( self, tenants_fixture, scans_fixture ): @@ -3601,19 +3674,19 @@ class TestAggregateFindingsByRegion: scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - # Mock findings with resources - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "FAIL" - mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1", "req2"]} - - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.small_resources = [mock_resource1] + # (check_id, status, resource_regions, compliance) tuples + finding_rows = [ + ( + "check1", + "FAIL", + ["us-east-1"], + {modeled_threatscore_compliance_id: ["req1", "req2"]}, + ) + ] mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [mock_finding1] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3627,6 +3700,12 @@ class TestAggregateFindingsByRegion: ) ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # Verify structure of check_status_by_region assert isinstance(check_status_by_region, dict) assert "us-east-1" in check_status_by_region @@ -3646,27 +3725,15 @@ class TestAggregateFindingsByRegion: scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - # First finding with PASS status - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "PASS" - mock_finding1.compliance = {} - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.small_resources = [mock_resource1] - - # Second finding with FAIL status for same check/region - mock_finding2 = MagicMock() - mock_finding2.check_id = "check1" - mock_finding2.status = "FAIL" - mock_finding2.compliance = {} - mock_resource2 = MagicMock() - mock_resource2.region = "us-east-1" - mock_finding2.small_resources = [mock_resource2] + # Same check/region: PASS first, then FAIL — FAIL must win + finding_rows = [ + ("check1", "PASS", ["us-east-1"], {}), + ("check1", "FAIL", ["us-east-1"], {}), + ] mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3678,6 +3745,12 @@ class TestAggregateFindingsByRegion: tenant_id, scan_id, modeled_threatscore_compliance_id ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # FAIL should override PASS assert check_status_by_region["us-east-1"]["check1"] == "FAIL" @@ -3692,8 +3765,8 @@ class TestAggregateFindingsByRegion: modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = [] ctx = MagicMock() ctx.__enter__.return_value = None @@ -3705,6 +3778,12 @@ class TestAggregateFindingsByRegion: tenant_id, scan_id, modeled_threatscore_compliance_id ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # Verify filter was called with muted=False mock_findings_filter.assert_called_once_with( tenant_id=tenant_id, @@ -3723,27 +3802,25 @@ class TestAggregateFindingsByRegion: scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - # Finding with PASS status - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "PASS" - mock_finding1.compliance = {modeled_threatscore_compliance_id: ["req1"]} - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.small_resources = [mock_resource1] - - # Finding with FAIL status - mock_finding2 = MagicMock() - mock_finding2.check_id = "check2" - mock_finding2.status = "FAIL" - mock_finding2.compliance = {modeled_threatscore_compliance_id: ["req1"]} - mock_resource2 = MagicMock() - mock_resource2.region = "us-east-1" - mock_finding2.small_resources = [mock_resource2] + # PASS and FAIL findings mapped to the same ThreatScore requirement + finding_rows = [ + ( + "check1", + "PASS", + ["us-east-1"], + {modeled_threatscore_compliance_id: ["req1"]}, + ), + ( + "check2", + "FAIL", + ["us-east-1"], + {modeled_threatscore_compliance_id: ["req1"]}, + ), + ] mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3755,6 +3832,12 @@ class TestAggregateFindingsByRegion: tenant_id, scan_id, modeled_threatscore_compliance_id ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # Verify compliance counts normalized_id = re.sub( r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() @@ -3777,27 +3860,15 @@ class TestAggregateFindingsByRegion: scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" - # Finding in us-east-1 - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "FAIL" - mock_finding1.compliance = {} - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.small_resources = [mock_resource1] - - # Finding in us-west-2 - mock_finding2 = MagicMock() - mock_finding2.check_id = "check1" - mock_finding2.status = "PASS" - mock_finding2.compliance = {} - mock_resource2 = MagicMock() - mock_resource2.region = "us-west-2" - mock_finding2.small_resources = [mock_resource2] + # One finding per region + finding_rows = [ + ("check1", "FAIL", ["us-east-1"], {}), + ("check1", "PASS", ["us-west-2"], {}), + ] mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [mock_finding1, mock_finding2] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3809,6 +3880,12 @@ class TestAggregateFindingsByRegion: tenant_id, scan_id, modeled_threatscore_compliance_id ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + # Verify both regions are present with correct statuses assert "us-east-1" in check_status_by_region assert "us-west-2" in check_status_by_region @@ -3817,17 +3894,26 @@ class TestAggregateFindingsByRegion: @patch("tasks.jobs.scan.Finding.all_objects.filter") @patch("tasks.jobs.scan.rls_transaction") - def test_aggregate_findings_by_region_empty_findings( + def test_aggregate_findings_by_region_multi_region_finding( self, mock_rls_transaction, mock_findings_filter ): - """Test with no findings - should return empty dicts.""" + """A finding with multiple resource_regions is tallied in every region.""" tenant_id = str(uuid.uuid4()) scan_id = str(uuid.uuid4()) modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + finding_rows = [ + ( + "check1", + "FAIL", + ["us-east-1", "eu-west-1"], + {modeled_threatscore_compliance_id: ["req1"]}, + ) + ] + mock_queryset = MagicMock() - mock_queryset.only.return_value = mock_queryset - mock_queryset.prefetch_related.return_value = [] + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows ctx = MagicMock() ctx.__enter__.return_value = None @@ -3841,6 +3927,92 @@ class TestAggregateFindingsByRegion: ) ) + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + + normalized_id = re.sub( + r"[^a-z0-9]", "", modeled_threatscore_compliance_id.lower() + ) + for region in ("us-east-1", "eu-west-1"): + assert check_status_by_region[region]["check1"] == "FAIL" + req_stats = findings_count_by_compliance[region][normalized_id]["req1"] + assert req_stats == {"total": 1, "pass": 0} + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_skips_empty_regions( + self, mock_rls_transaction, mock_findings_filter + ): + """A finding with no denormalized regions contributes nothing.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + + finding_rows = [ + ("check1", "FAIL", [], {modeled_threatscore_compliance_id: ["req1"]}), + ("check2", "PASS", None, {}), + ] + + mock_queryset = MagicMock() + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = finding_rows + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + check_status_by_region, findings_count_by_compliance = ( + _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + ) + + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + + assert check_status_by_region == {} + assert findings_count_by_compliance == {} + + @patch("tasks.jobs.scan.Finding.all_objects.filter") + @patch("tasks.jobs.scan.rls_transaction") + def test_aggregate_findings_by_region_empty_findings( + self, mock_rls_transaction, mock_findings_filter + ): + """Test with no findings - should return empty dicts.""" + tenant_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + modeled_threatscore_compliance_id = "ProwlerThreatScore-1.0" + + mock_queryset = MagicMock() + mock_queryset.values_list.return_value = mock_queryset + mock_queryset.iterator.return_value = [] + + ctx = MagicMock() + ctx.__enter__.return_value = None + ctx.__exit__.return_value = False + mock_rls_transaction.return_value = ctx + mock_findings_filter.return_value = mock_queryset + + check_status_by_region, findings_count_by_compliance = ( + _aggregate_findings_by_region( + tenant_id, scan_id, modeled_threatscore_compliance_id + ) + ) + + # Streaming query contract: column-scoped values_list + iterator + mock_queryset.values_list.assert_called_once_with( + "check_id", "status", "resource_regions", "compliance" + ) + mock_queryset.iterator.assert_called_once() + assert check_status_by_region == {} assert findings_count_by_compliance == {} diff --git a/api/uv.lock b/api/uv.lock index 9689a9cd0a..3b5cca9b6a 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -16,7 +16,7 @@ constraints = [ { name = "aiobotocore", specifier = "==2.25.1" }, { name = "aiofiles", specifier = "==24.1.0" }, { name = "aiohappyeyeballs", specifier = "==2.6.1" }, - { name = "aiohttp", specifier = "==3.13.5" }, + { name = "aiohttp", specifier = "==3.14.0" }, { name = "aioitertools", specifier = "==0.13.0" }, { name = "aiosignal", specifier = "==1.4.0" }, { name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" }, @@ -61,9 +61,8 @@ constraints = [ { name = "astroid", specifier = "==3.2.4" }, { name = "async-timeout", specifier = "==5.0.1" }, { name = "attrs", specifier = "==25.4.0" }, - { name = "authlib", specifier = "==1.6.9" }, + { name = "authlib", specifier = "==1.6.12" }, { name = "autopep8", specifier = "==2.3.2" }, - { name = "awsipranges", specifier = "==0.3.3" }, { name = "azure-cli-core", specifier = "==2.83.0" }, { name = "azure-cli-telemetry", specifier = "==1.1.0" }, { name = "azure-common", specifier = "==1.1.28" }, @@ -146,6 +145,7 @@ constraints = [ { name = "django-celery-results", specifier = "==2.6.0" }, { name = "django-cors-headers", specifier = "==4.4.0" }, { name = "django-environ", specifier = "==0.11.2" }, + { name = "django-eventstream", specifier = "==5.3.3" }, { name = "django-filter", specifier = "==24.3" }, { name = "django-guid", specifier = "==3.5.0" }, { name = "django-postgres-extra", specifier = "==2.0.9" }, @@ -190,7 +190,7 @@ constraints = [ { name = "grpc-google-iam-v1", specifier = "==0.14.3" }, { name = "grpcio", specifier = "==1.76.0" }, { name = "grpcio-status", specifier = "==1.76.0" }, - { name = "gunicorn", specifier = "==23.0.0" }, + { name = "gunicorn", specifier = "==26.0.0" }, { name = "h11", specifier = "==0.16.0" }, { name = "h2", specifier = "==4.3.0" }, { name = "hpack", specifier = "==4.1.0" }, @@ -199,8 +199,8 @@ constraints = [ { name = "httpx", specifier = "==0.28.1" }, { name = "humanfriendly", specifier = "==10.0" }, { name = "hyperframe", specifier = "==6.1.0" }, - { name = "iamdata", specifier = "==0.1.202602021" }, - { name = "idna", specifier = "==3.11" }, + { name = "iamdata", specifier = "==0.1.202605131" }, + { name = "idna", specifier = "==3.15" }, { name = "importlib-metadata", specifier = "==8.7.1" }, { name = "inflection", specifier = "==0.5.1" }, { name = "iniconfig", specifier = "==2.3.0" }, @@ -252,7 +252,7 @@ constraints = [ { name = "neo4j", specifier = "==6.1.0" }, { name = "nest-asyncio", specifier = "==1.6.0" }, { name = "nltk", specifier = "==3.9.4" }, - { name = "numpy", specifier = "==2.0.2" }, + { name = "numpy", specifier = "==2.2.6" }, { name = "oauthlib", specifier = "==3.3.1" }, { name = "oci", specifier = "==2.169.0" }, { name = "openai", specifier = "==1.109.1" }, @@ -281,7 +281,7 @@ constraints = [ { name = "psutil", specifier = "==7.2.2" }, { name = "psycopg2-binary", specifier = "==2.9.9" }, { name = "py-deviceid", specifier = "==0.1.1" }, - { name = "py-iam-expand", specifier = "==0.1.0" }, + { name = "py-iam-expand", specifier = "==0.3.0" }, { name = "py-ocsf-models", specifier = "==0.8.1" }, { name = "pyasn1", specifier = "==0.6.3" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, @@ -357,6 +357,8 @@ constraints = [ { name = "uritemplate", specifier = "==4.2.0" }, { name = "urllib3", specifier = "==2.7.0" }, { name = "uuid6", specifier = "==2024.7.10" }, + { name = "uvicorn", specifier = "==0.49.0" }, + { name = "uvloop", specifier = "==0.22.1" }, { name = "vine", specifier = "==5.1.0" }, { name = "vulture", specifier = "==2.14" }, { name = "wcwidth", specifier = "==0.5.3" }, @@ -469,7 +471,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -478,44 +480,47 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" }, + { url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" }, + { url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" }, + { url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, + { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, ] [[package]] @@ -1045,15 +1050,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, ] -[[package]] -name = "awsipranges" -version = "0.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/2e/6efa95f995369da828715f41705686cd214b9259ed758266942553d40441/awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0", size = 16739, upload-time = "2022-02-10T21:08:32.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ce/5c9a8bf91bdc9592a409c99e58fd99f2727ab8d634719c0ad796021b76d7/awsipranges-0.3.3-py3-none-any.whl", hash = "sha256:f3d7a54aeaf7fe310beb5d377a4034a63a51b72677ae6af3e0967bc4de7eedaf", size = 18106, upload-time = "2022-02-10T21:08:31.497Z" }, -] - [[package]] name = "azure-cli-core" version = "2.83.0" @@ -2362,6 +2358,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f1/468b49cccba3b42dda571063a14c668bb0b53a1d5712426d18e36663bd53/django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05", size = 19141, upload-time = "2023-09-01T21:02:59.88Z" }, ] +[[package]] +name = "django-eventstream" +version = "5.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-grip" }, + { name = "gripcontrol" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/49/ec6cbc24f3f30465370df7096cfea9722bad2b0c1f35a7ff5d45fb96cff6/django_eventstream-5.3.3.tar.gz", hash = "sha256:6880b03298eebf18c1b736b972fb862eaf631dfbb79f8b27496418a3495d08dc", size = 47622, upload-time = "2025-10-23T00:22:40.291Z" } + [[package]] name = "django-filter" version = "24.3" @@ -2374,6 +2383,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" }, ] +[[package]] +name = "django-grip" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "gripcontrol" }, + { name = "pubcontrol" }, + { name = "six" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/d0/2c7b04fa864073cd8cb324f8674672162282d97540d56732cbd3a9ae5bca/django-grip-3.5.2.tar.gz", hash = "sha256:1ee1601492cd110256bd03e4a68797a9fbefa27c15f5a838bf245df97db0450c", size = 7626, upload-time = "2025-03-24T18:53:58.677Z" } + [[package]] name = "django-guid" version = "3.5.0" @@ -2985,6 +3007,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, ] +[[package]] +name = "gripcontrol" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pubcontrol" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/51/1cbf88384fbe97a1454fb0adddcdca8cb90ceb99c3250274c334db844f4f/gripcontrol-4.4.0.tar.gz", hash = "sha256:44ee6fe244a02870aa4e5bc810138ccaf5070dce5eb149b8ee9e27b960a95c2d", size = 12526, upload-time = "2026-05-14T21:19:28.49Z" } + [[package]] name = "grpc-google-iam-v1" version = "0.14.3" @@ -3046,14 +3079,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "23.0.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] @@ -3155,20 +3188,20 @@ wheels = [ [[package]] name = "iamdata" -version = "0.1.202602021" +version = "0.1.202605131" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/5e/8179963b7a528c548824a8e4088150509d9fa8571dd622b7399f6d2d5680/iamdata-0.1.202602021.tar.gz", hash = "sha256:c24265fc3694076f65da91a8aa9361b60da25f7b8cfd8ba4ddd6aa1b9bb5153e", size = 771233, upload-time = "2026-02-02T05:49:56.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/ea/d68e25aa4392e8a9f8e6523adc95a5fb86baf98d052efa2cec4d4a00e7ce/iamdata-0.1.202605131.tar.gz", hash = "sha256:ab4e8f1ea080394157848fecd0ca643633e35b2e0cb1965c9ed9bdd673afe00c", size = 793465, upload-time = "2026-05-13T05:57:10.607Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/9e/ae7a3019aa5a27d70412b74da4f0304695efa5d9a88f0689f37ea2602ea2/iamdata-0.1.202602021-py3-none-any.whl", hash = "sha256:48419662d75dd0e1ea22b9cc98fd70201d4c72760c6897acc46ad9ab90633d18", size = 1226614, upload-time = "2026-02-02T05:49:54.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/93/03396b477b0faa9f1a12142209b59aa13d0fe4f64e2be47883f607789c14/iamdata-0.1.202605131-py3-none-any.whl", hash = "sha256:350e317d96fb8c8ddf30aa6da222788d302af5f13c9e357b59f9eefe50b8af5a", size = 1259166, upload-time = "2026-05-13T05:57:09.093Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -3971,30 +4004,30 @@ wheels = [ [[package]] name = "numpy" -version = "2.0.2" +version = "2.2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, - { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, - { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, - { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, - { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, - { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, ] [[package]] @@ -4415,8 +4448,8 @@ wheels = [ [[package]] name = "prowler" -version = "5.27.0" -source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" } +version = "5.31.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b5bb85c9564f6ca6a7f66c851bb56bde719205ee" } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, { name = "alibabacloud-credentials" }, @@ -4432,7 +4465,6 @@ dependencies = [ { name = "alibabacloud-tea-openapi" }, { name = "alibabacloud-vpc20160428" }, { name = "alive-progress" }, - { name = "awsipranges" }, { name = "azure-identity" }, { name = "azure-keyvault-keys" }, { name = "azure-mgmt-apimanagement" }, @@ -4489,9 +4521,14 @@ dependencies = [ { name = "pygithub" }, { name = "python-dateutil" }, { name = "pytz" }, + { name = "scaleway" }, { name = "schema" }, { name = "shodan" }, { name = "slack-sdk" }, + { name = "stackit-core" }, + { name = "stackit-iaas" }, + { name = "stackit-objectstorage" }, + { name = "stackit-resourcemanager" }, { name = "tabulate" }, { name = "tzlocal" }, { name = "uuid6" }, @@ -4499,7 +4536,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.31.0" +version = "1.32.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, @@ -4512,6 +4549,7 @@ dependencies = [ { name = "django-celery-results" }, { name = "django-cors-headers" }, { name = "django-environ" }, + { name = "django-eventstream" }, { name = "django-filter" }, { name = "django-guid" }, { name = "django-postgres-extra" }, @@ -4538,6 +4576,8 @@ dependencies = [ { name = "sentry-sdk", extra = ["django"] }, { name = "sqlparse" }, { name = "uuid6" }, + { name = "uvicorn-worker" }, + { name = "uvloop" }, { name = "werkzeug" }, { name = "xmlsec" }, ] @@ -4576,6 +4616,7 @@ requires-dist = [ { name = "django-celery-results", specifier = "==2.6.0" }, { name = "django-cors-headers", specifier = "==4.4.0" }, { name = "django-environ", specifier = "==0.11.2" }, + { name = "django-eventstream", specifier = "==5.3.3" }, { name = "django-filter", specifier = "==24.3" }, { name = "django-guid", specifier = "==3.5.0" }, { name = "django-postgres-extra", specifier = "==2.0.9" }, @@ -4588,7 +4629,7 @@ requires-dist = [ { name = "drf-spectacular-jsonapi", specifier = "==0.5.1" }, { name = "fonttools", specifier = "==4.62.1" }, { name = "gevent", specifier = "==25.9.1" }, - { name = "gunicorn", specifier = "==23.0.0" }, + { name = "gunicorn", specifier = "==26.0.0" }, { name = "h2", specifier = "==4.3.0" }, { name = "lxml", specifier = "==6.1.0" }, { name = "markdown", specifier = "==3.10.2" }, @@ -4602,6 +4643,8 @@ requires-dist = [ { name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" }, { name = "sqlparse", specifier = "==0.5.5" }, { name = "uuid6", specifier = "==2024.7.10" }, + { name = "uvicorn-worker", specifier = "==0.4.0" }, + { name = "uvloop", specifier = "==0.22.1" }, { name = "werkzeug", specifier = "==3.1.7" }, { name = "xmlsec", specifier = "==1.3.17" }, ] @@ -4676,6 +4719,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/08/9c66c269b0d417a0af9fb969535f0371b8c538633535a7a6a5ca3f9231e2/psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", size = 1163864, upload-time = "2023-10-28T09:37:28.155Z" }, ] +[[package]] +name = "pubcontrol" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/6a/02202a247214a6ffd5148ab1b16aca1c334b40dca211bca0442c8b7c7447/pubcontrol-3.5.0.tar.gz", hash = "sha256:a5ec6b3f53edfd005675518e5e4cc23b34122776835ae7c6dbd1db173d1ff0cb", size = 18199, upload-time = "2023-07-05T19:11:40.477Z" } + [[package]] name = "py-deviceid" version = "0.1.1" @@ -4687,14 +4740,14 @@ wheels = [ [[package]] name = "py-iam-expand" -version = "0.1.0" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "iamdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/99/8d31a30b37825577275bb3663885b55075fba80257fcd6813b85d3aaffa8/py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96", size = 10228, upload-time = "2025-04-30T07:15:35.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/08/f6e11a029b81f0bec4b7b1f18704aadf509a882cc386c90ef1ac043c18cc/py_iam_expand-0.3.0.tar.gz", hash = "sha256:4ccfe25f40ba0633a152c4f86b49cde8972ee3d4b6009b017a4310cc4b9e64c7", size = 10234, upload-time = "2026-02-24T09:47:47.772Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/19/482c2e0768cda7afaed07918e4fbd951e2418255fb5d1d9b35b284871716/py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510", size = 12522, upload-time = "2025-04-30T07:15:33.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dd/4056d0bc3d6317039d2dd2ee7cd6a5389575603e270399a8f9f20e11e721/py_iam_expand-0.3.0-py3-none-any.whl", hash = "sha256:94c0a1e9dd60316ce60ddc0cdc9a046119bde335b5bb9593ee29224857860d5a", size = 12527, upload-time = "2026-02-24T09:47:45.602Z" }, ] [[package]] @@ -5531,6 +5584,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "stackit-core" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" }, +] + +[[package]] +name = "stackit-iaas" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" }, +] + +[[package]] +name = "stackit-objectstorage" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" }, +] + +[[package]] +name = "stackit-resourcemanager" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" }, +] + [[package]] name = "statsd" version = "4.0.1" @@ -5735,6 +5849,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/3e/4ae6af487ce5781ed71d5fe10aca72e7cbc4d4f45afc31b120287082a8dd/uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7", size = 6376, upload-time = "2024-07-10T16:39:36.148Z" }, ] +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[[package]] +name = "uvicorn-worker" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gunicorn" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + [[package]] name = "vine" version = "5.1.0" diff --git a/contrib/aws/multi-account-securityhub/Dockerfile b/contrib/aws/multi-account-securityhub/Dockerfile index 1cc6c326b7..de5c57ba7b 100644 --- a/contrib/aws/multi-account-securityhub/Dockerfile +++ b/contrib/aws/multi-account-securityhub/Dockerfile @@ -1,7 +1,7 @@ # Build command # docker build --platform=linux/amd64 --no-cache -t prowler:latest . -ARG PROWLER_VERSION=latest +ARG PROWLER_VERSION=latest@sha256:4b796c6df40a3350c7947747b59bdda230d0da6222287500e13b0a8e1574aad4 FROM toniblyx/prowler:${PROWLER_VERSION} diff --git a/contrib/inventory-graph/lib/inventory_output.py b/contrib/inventory-graph/lib/inventory_output.py index 359e7eb91f..459dc37cdb 100644 --- a/contrib/inventory-graph/lib/inventory_output.py +++ b/contrib/inventory-graph/lib/inventory_output.py @@ -16,7 +16,6 @@ from typing import Optional from prowler.lib.logger import logger from lib.models import ConnectivityGraph - # --------------------------------------------------------------------------- # JSON output # --------------------------------------------------------------------------- diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index 81eda5f690..a6074c7852 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -28,7 +28,7 @@ containers: image: repository: prowlercloud/prowler-api pullPolicy: IfNotPresent - command: ["../docker-entrypoint.sh", "beat"] + command: ["/home/prowler/docker-entrypoint.sh", "beat"] secrets: POSTGRES_HOST: @@ -438,6 +438,34 @@ mainConfig: # Minimum number of Availability Zones that an ELBv2 must be in elbv2_min_azs: 2 + # AWS Post-Quantum TLS Configuration + # aws.acmpca_certificate_authority_pqc_key_algorithm + acmpca_pqc_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + # aws.cloudfront_distributions_pqc_tls_enabled + cloudfront_pqc_min_protocol_versions: + - "TLSv1.3_2025" + # aws.apigateway_domain_name_pqc_tls_enabled + apigateway_pqc_tls_allowed_policies: + - "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PQ_2025_09" + + # AWS Post-Quantum SSH Key Exchange Configuration + # aws.transfer_server_pqc_ssh_kex_enabled + transfer_pqc_ssh_allowed_policies: + - "TransferSecurityPolicy-2025-03" + - "TransferSecurityPolicy-FIPS-2025-03" + - "TransferSecurityPolicy-AS2Restricted-2025-07" + + + # aws.rolesanywhere_trust_anchor_pqc_pki + rolesanywhere_pqc_pca_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" # AWS Secrets Configuration # Patterns to ignore in the secrets checks diff --git a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml index 38d6e65ee3..856770722a 100644 --- a/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml +++ b/contrib/k8s/helm/prowler-app/templates/ui/configmap.yaml @@ -11,8 +11,7 @@ data: {{- else }} AUTH_URL: {{ .Values.ui.authUrl | quote }} {{- end }} - API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" - NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" + UI_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1" + UI_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs" AUTH_TRUST_HOST: "true" UI_PORT: {{ .Values.ui.service.port | quote }} diff --git a/contrib/k8s/helm/prowler-app/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml index 9acca09f2a..ed390af29e 100644 --- a/contrib/k8s/helm/prowler-app/values.yaml +++ b/contrib/k8s/helm/prowler-app/values.yaml @@ -440,7 +440,7 @@ worker_beat: tag: "" command: - - ../docker-entrypoint.sh + - /home/prowler/docker-entrypoint.sh args: - beat diff --git a/contrib/k8s/helm/prowler-ui/values.yaml b/contrib/k8s/helm/prowler-ui/values.yaml index d4f2dbe137..aca0178b3a 100644 --- a/contrib/k8s/helm/prowler-ui/values.yaml +++ b/contrib/k8s/helm/prowler-ui/values.yaml @@ -21,8 +21,8 @@ fullnameOverride: "" secrets: SITE_URL: http://localhost:3000 - API_BASE_URL: http://prowler-api:8080/api/v1 - NEXT_PUBLIC_API_DOCS_URL: http://prowler-api:8080/api/v1/docs + UI_API_BASE_URL: http://prowler-api:8080/api/v1 + UI_API_DOCS_URL: http://prowler-api:8080/api/v1/docs AUTH_TRUST_HOST: True UI_PORT: 3000 # openssl rand -base64 32 diff --git a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml index 08c52f3558..b8f8edec30 100644 --- a/contrib/reverse-proxy/docker-compose.reverse-proxy.yml +++ b/contrib/reverse-proxy/docker-compose.reverse-proxy.yml @@ -16,7 +16,7 @@ services: nginx: - image: nginx:alpine + image: nginx:alpine@sha256:8b1e78743a03dbb2c95171cc58639fef29abc8816598e27fb910ed2e621e589a container_name: prowler-nginx restart: unless-stopped ports: diff --git a/dashboard/common_methods.py b/dashboard/common_methods.py index a2f9ffe89b..b9f59513a5 100644 --- a/dashboard/common_methods.py +++ b/dashboard/common_methods.py @@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2): return html.Div(section_containers, className="compliance-data-layout") +def _status_bar(success, failed, classname): + """Build the stacked PASS/FAIL bar shown next to an accordion title.""" + fig = go.Figure( + data=[ + go.Bar( + name="Failed", + x=[failed], + y=[""], + orientation="h", + marker=dict(color="#e77676"), + width=[0.8], + ), + go.Bar( + name="Success", + x=[success], + y=[""], + orientation="h", + marker=dict(color="#45cc6e"), + width=[0.8], + ), + ] + ) + fig.update_layout( + barmode="stack", + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + showlegend=False, + width=350, + height=30, + xaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + yaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + annotations=[ + dict( + x=success + failed, + y=0, + xref="x", + yref="y", + text=str(success), + showarrow=False, + font=dict(color="#45cc6e", size=14), + xanchor="left", + yanchor="middle", + ), + dict( + x=0, + y=0, + xref="x", + yref="y", + text=str(failed), + showarrow=False, + font=dict(color="#e77676", size=14), + xanchor="right", + yanchor="middle", + ), + ], + ) + fig.add_annotation( + x=failed, + y=0.3, + text="|", + showarrow=False, + xanchor="center", + yanchor="middle", + font=dict(size=20), + ) + return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname) + + +def get_section_containers_generic(data, section_col, id_col): + """Two-level view: section -> requirement id (+ description) -> checks. + + Sorts lexicographically so arbitrary requirement IDs never crash the + version-aware sort used by the CIS renderer. + """ + data["STATUS"] = data["STATUS"].apply(map_status_to_icon) + data[section_col] = data[section_col].astype(str) + data[id_col] = data[id_col].astype(str) + data.sort_values(by=[section_col, id_col], inplace=True) + + counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0) + counts_id = ( + data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0) + ) + + def count(counts, key, emoji): + return counts.loc[key, emoji] if emoji in counts.columns else 0 + + has_description = "REQUIREMENTS_DESCRIPTION" in data.columns + table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"] + + section_containers = [] + for section in data[section_col].unique(): + graph_div = html.Div( + _status_bar( + count(counts_section, section, pass_emoji), + count(counts_section, section, fail_emoji), + "info-bar", + ), + className="graph-section", + ) + + internal_items = [] + for req_id in data[data[section_col] == section][id_col].unique(): + specific_data = data[ + (data[section_col] == section) & (data[id_col] == req_id) + ] + data_table = dash_table.DataTable( + data=specific_data.to_dict("records"), + columns=[ + {"name": i, "id": i} + for i in table_cols + if i in specific_data.columns + ], + style_table={"overflowX": "auto"}, + style_as_list_view=True, + style_cell={"textAlign": "left", "padding": "5px"}, + ) + graph_div_req = html.Div( + _status_bar( + count(counts_id, (section, req_id), pass_emoji), + count(counts_id, (section, req_id), fail_emoji), + "info-bar-child", + ), + className="graph-section-req", + ) + + title = req_id + if has_description: + title = ( + f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}" + ) + if len(title) > 130: + title = title[:130] + " ..." + + internal_items.append( + html.Div( + [ + graph_div_req, + dbc.Accordion( + [ + dbc.AccordionItem( + title=title, + children=[ + html.Div( + [data_table], + className="inner-accordion-content", + ) + ], + ) + ], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner--child", + ) + ) + + section_containers.append( + html.Div( + [ + graph_div, + dbc.Accordion( + [ + dbc.AccordionItem( + title=f"{section}", children=internal_items + ) + ], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner", + ) + ) + + return html.Div(section_containers, className="compliance-data-layout") + + def get_section_containers_format4(data, section_1): data["STATUS"] = data["STATUS"].apply(map_status_to_icon) diff --git a/dashboard/compliance/generic.py b/dashboard/compliance/generic.py new file mode 100644 index 0000000000..f7d68bb52a --- /dev/null +++ b/dashboard/compliance/generic.py @@ -0,0 +1,44 @@ +import warnings + +from dashboard.common_methods import ( + get_section_containers_format4, + get_section_containers_generic, +) + +warnings.filterwarnings("ignore") + + +def get_table(data): + # Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime. + attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")] + + # Section column (in priority order): + # 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention + # 2. First discovered attribute column — covers novel schemas + # 3. None — no section, group flat by requirement id + if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols: + section_col = "REQUIREMENTS_ATTRIBUTES_SECTION" + elif attr_cols: + section_col = attr_cols[0] + else: + section_col = None + + base_cols = [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "STATUS", + "CHECKID", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + + # Two levels (section -> requirement id) when a section distinct from the + # id exists; otherwise group flat by requirement id. + if section_col and section_col != "REQUIREMENTS_ID": + needed = [section_col] + base_cols + aux = data[[c for c in needed if c in data.columns]].copy() + return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID") + + aux = data[[c for c in base_cols if c in data.columns]].copy() + return get_section_containers_format4(aux, "REQUIREMENTS_ID") diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index 930432b6c4..3fb230f314 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -156,7 +156,7 @@ def create_layout_compliance( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://prowler.pro/", + href="https://cloud.prowler.com/", target="_blank", className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10", ), diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index c1da9f611e..773dd095da 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -215,6 +215,58 @@ else: ) +def _ensure_scope_columns(data): + """Guarantee ACCOUNTID and REGION exist. + + Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive + them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and + fall back to "-" to avoid a KeyError. + """ + cols = list(data.columns) + scope = [] + if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols: + start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE") + scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")] + + if "ACCOUNTID" not in data.columns: + if scope: + data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True) + else: + data["ACCOUNTID"] = "-" + if "REGION" not in data.columns: + if scope: + data.rename(columns={scope.pop(0): "REGION"}, inplace=True) + else: + data["REGION"] = "-" + return data + + +def _dispatch_compliance_renderer(data, analytics_input): + """Resolve the compliance renderer module and return (table, deduped_data). + + Tries to import the framework-specific builtin module. On + ModuleNotFoundError (dynamic/external provider with no dedicated module), + falls back to the generic renderer. Any other ImportError is re-raised. + get_table() is called OUTSIDE the try block so errors inside the renderer + surface as real exceptions rather than being swallowed. + """ + current = analytics_input.replace(".", "_") + target = f"dashboard.compliance.{current}" + try: + module = importlib.import_module(target) + except ModuleNotFoundError as exc: + if exc.name != target: + raise + from dashboard.compliance import generic as module + dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] + if "MUTED" in data.columns: + dedup_columns.insert(2, "MUTED") + data = data.drop_duplicates(subset=dedup_columns) + if "threatscore" in analytics_input: + data = get_threatscore_mean_by_pillar(data) + return module.get_table(data), data + + @callback( [ Output("output", "children"), @@ -292,7 +344,7 @@ def display_data( data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True) # Filter the chosen level of the CIS - if is_level_1: + if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns: data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")] # Rename the column PROJECTID to ACCOUNTID for GCP @@ -314,6 +366,9 @@ def display_data( data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True) data["REGION"] = "-" + # Normalize scope columns for any remaining (e.g. dynamic) provider. + data = _ensure_scope_columns(data) + # Filter ACCOUNT if account_filter == ["All"]: updated_cloud_account_values = data["ACCOUNTID"].unique() @@ -409,36 +464,7 @@ def display_data( # Check cases where the compliance start with AWS_ if "aws_" in analytics_input: analytics_input = analytics_input + "_aws" - try: - current = analytics_input.replace(".", "_") - compliance_module = importlib.import_module( - f"dashboard.compliance.{current}" - ) - # Build subset list based on available columns - dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"] - if "MUTED" in data.columns: - dedup_columns.insert(2, "MUTED") - data = data.drop_duplicates(subset=dedup_columns) - - if "threatscore" in analytics_input: - data = get_threatscore_mean_by_pillar(data) - - table = compliance_module.get_table(data) - except ModuleNotFoundError: - table = html.Div( - [ - html.H5( - "No data found for this compliance", - className="card-title", - style={"text-align": "left", "color": "black"}, - ) - ], - style={ - "width": "99%", - "margin-right": "0.8%", - "margin-bottom": "10px", - }, - ) + table, data = _dispatch_compliance_renderer(data, analytics_input) df = data.copy() # Remove Muted rows diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index 665aa8e195..e705f15e9f 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -1538,7 +1538,7 @@ def filter_data( html.Img(src="assets/favicon.ico", className="w-5 mr-3"), html.Span("Subscribe to Prowler Cloud"), ], - href="https://prowler.pro/", + href="https://cloud.prowler.com/", target="_blank", className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10", ), diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 522aaf5413..d737298183 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,6 +1,6 @@ services: api-dev-init: - image: busybox:1.37.0 + image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 volumes: - ./_data/api:/data command: ["sh", "-c", "chown -R 1000:1000 /data"] @@ -64,7 +64,7 @@ services: condition: service_healthy postgres: - image: postgres:16.3-alpine3.20 + image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41 hostname: "postgres-db" volumes: - ./_data/postgres:/var/lib/postgresql/data @@ -88,7 +88,7 @@ services: retries: 5 valkey: - image: valkey/valkey:7-alpine3.19 + image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc hostname: "valkey" volumes: - ./_data/valkey:/data @@ -104,7 +104,7 @@ services: retries: 3 neo4j: - image: graphstack/dozerdb:5.26.3.0 + image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247 hostname: "neo4j" volumes: - ./_data/neo4j:/data @@ -185,7 +185,7 @@ services: soft: 65536 hard: 65536 entrypoint: - - "../docker-entrypoint.sh" + - "/home/prowler/docker-entrypoint.sh" - "beat" mcp-server: diff --git a/docker-compose.yml b/docker-compose.yml index 1519946afd..6cb5ac237d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ # services: api-init: - image: busybox:1.37.0 + image: busybox:1.37.0@sha256:9532d8c39891ca2ecde4d30d7710e01fb739c87a8b9299685c63704296b16028 volumes: - ./_data/api:/data command: ["sh", "-c", "chown -R 1000:1000 /data"] @@ -60,7 +60,7 @@ services: start_period: 60s postgres: - image: postgres:16.3-alpine3.20 + image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41 hostname: "postgres-db" volumes: - ./_data/postgres:/var/lib/postgresql/data @@ -80,7 +80,7 @@ services: retries: 5 valkey: - image: valkey/valkey:7-alpine3.19 + image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc hostname: "valkey" volumes: - ./_data/valkey:/data @@ -96,7 +96,7 @@ services: retries: 3 neo4j: - image: graphstack/dozerdb:5.26.3.0 + image: graphstack/dozerdb:5.26.3.0@sha256:a77526ea3918fdc46d1fff70c4aea7d71d3874a26ecec059179d6775845b1247 hostname: "neo4j" volumes: - ./_data/neo4j:/data @@ -160,7 +160,7 @@ services: soft: 65536 hard: 65536 entrypoint: - - "../docker-entrypoint.sh" + - "/home/prowler/docker-entrypoint.sh" - "beat" mcp-server: diff --git a/docs/developer-guide/configurable-checks.mdx b/docs/developer-guide/configurable-checks.mdx index d7b1089060..760dc80f58 100644 --- a/docs/developer-guide/configurable-checks.mdx +++ b/docs/developer-guide/configurable-checks.mdx @@ -112,4 +112,109 @@ Say a new check needs `max_iam_role_session_hours`, a strictly positive integer - **Coerced values are normalized.** A YAML-quoted `"180"` for an `int` field arrives downstream as the integer `180`. - **The shipped `config.yaml` must round-trip cleanly.** The integration test `test_shipped_default_config_loads_without_warnings` will fail if a key is added to the YAML without a matching schema field, so the two stay in sync. +## Configuration Value Limits + +Configurable thresholds enforce hard limits. A value outside the documented range is **dropped with a warning** and the check falls back to its built-in default (the same as if the key were absent). These bounds are intentionally conservative: they are not the absolute service maxima but the range that still produces a meaningful security check. + +Use this section as the reference when upgrading an existing config: if a value you set is being rejected, it is outside the range below. + +Only fields with a numeric range, a fixed value set, or a length cap are listed. Fields typed as free-form strings or lists (for example `disallowed_regions`, `secrets_ignore_patterns`, `trusted_account_ids`) have no range limit — they are validated for shape only (a 12-digit account ID, a valid IP/CIDR, a dotted version string), not for magnitude. + +### AWS + +| Key | Allowed range | Notes | +|---|---|---| +| `max_unused_access_keys_days` | `30..180` days | CIS AWS 1.13 recommends 45; NIST IA-5 ≤90 | +| `max_console_access_days` | `30..180` days | CIS AWS 1.12 recommends 45 | +| `max_unused_sagemaker_access_days` | `7..180` days | | +| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group | +| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years | +| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved | +| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min | +| `max_disconnect_timeout_in_seconds` | `60..3600` s | | +| `max_session_duration_seconds` | `600..86400` s | 10 min .. 24 h (AppStream per-session hard limit) | +| `lambda_min_azs` | `1..6` | | +| `recommended_cdk_bootstrap_version` | `1..100` | | +| `log_group_retention_days` | one of `1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653` | only the CloudWatch Logs API-accepted retention values | +| `threat_detection_privilege_escalation_threshold` | `0.0..1.0` | fraction of suspicious actions | +| `threat_detection_privilege_escalation_minutes` | `5..43200` min | under 5 min the signal is mostly false positives | +| `threat_detection_enumeration_threshold` | `0.0..1.0` | | +| `threat_detection_enumeration_minutes` | `5..43200` min | | +| `threat_detection_llm_jacking_threshold` | `0.0..1.0` | | +| `threat_detection_llm_jacking_minutes` | `5..43200` min | | +| `days_to_expire_threshold` (ACM) | `7..365` days | PCI-DSS 4.2.1.1: alert ≥30 days before expiry | +| `elb_min_azs` | `1..6` | | +| `elbv2_min_azs` | `1..6` | | +| `minimum_snapshot_retention_period` | `1..35` days | ElastiCache service hard limit | +| `max_days_secret_unused` | `7..365` days | | +| `max_days_secret_unrotated` | `1..180` days | NIST IA-5: rotate quarterly; CIS ≤90 | +| `min_kinesis_stream_retention_hours` | `24..8760` h | 1 day .. 1 year | +| `detect_secrets_plugins[].limit` | `0.0..10.0` | Shannon entropy threshold | +| `shodan_api_key` | ≤512 chars | | + +### Azure + +| Key | Allowed range | Notes | +|---|---|---| +| `vm_backup_min_daily_retention_days` | `7..9999` days | Azure Backup hard limit; under 7 days defeats DR/ransomware recovery | +| `apim_threat_detection_llm_jacking_threshold` | `0.0..1.0` | fraction of suspicious actions | +| `apim_threat_detection_llm_jacking_minutes` | `5..43200` min | under 5 min the signal is mostly false positives | +| `shodan_api_key` | ≤512 chars | | + +### GCP + +| Key | Allowed range | Notes | +|---|---|---| +| `mig_min_zones` | `1..5` | | +| `max_snapshot_age_days` | `1..1095` days | 3 years | +| `max_unused_account_days` | `30..365` days | | +| `storage_min_retention_days` | `1..3650` days | | +| `shodan_api_key` | ≤512 chars | | + +### Kubernetes + +| Key | Allowed range | Notes | +|---|---|---| +| `audit_log_maxbackup` | `2..1000` | CIS Kubernetes 1.2.18 recommends ≥10 | +| `audit_log_maxsize` | `10..10000` MB | CIS Kubernetes 1.2.19 recommends ≥100 MB | +| `audit_log_maxage` | `7..3650` days | CIS Kubernetes 1.2.17 recommends ≥30 days | + +### M365 + +| Key | Allowed range | Notes | +|---|---|---| +| `sign_in_frequency` | `1..168` h | 1 h .. 7 days; Conditional Access baseline for admins ≤24 h | +| `recommended_mailtips_large_audience_threshold` | `5..10000` | Microsoft default 25 | +| `audit_log_age` | `30..3650` days | M365 E3 default 90 days; SEC/FINRA require ≥7 years | + +### GitHub + +| Key | Allowed range | Notes | +|---|---|---| +| `inactive_not_archived_days_threshold` | `30..3650` days | CIS GitHub recommends 180 | + +### Cloudflare + +| Key | Allowed range | Notes | +|---|---|---| +| `max_retries` | `0..10` | 0 disables retries | + +### MongoDB Atlas + +| Key | Allowed range | Notes | +|---|---|---| +| `max_service_account_secret_validity_hours` | `1..720` h | 1 h .. 30 days | + +### Vercel + +| Key | Allowed range | Notes | +|---|---|---| +| `days_to_expire_threshold` | `7..365` days | PCI-DSS 4.2.1.1: alert ≥30 days before expiry | +| `stale_token_threshold_days` | `30..3650` days | NIST AC-2(3) typical window 30..90 days | +| `stale_invitation_threshold_days` | `7..365` days | | +| `max_owner_percentage` | `1..50` % | guidance recommends ≤25% | +| `max_owners` | `1..1000` | absolute cap, overrides percentage for large teams | + +These bounds live in the provider schemas under `prowler/config/schema/`; each field's `Field(ge=..., le=...)` (or `field_validator`) is the source of truth and the descriptions there carry the full rationale. + This approach ensures that checks are easily configurable, making Prowler highly adaptable to different environments and requirements. diff --git a/docs/developer-guide/end2end-testing.mdx b/docs/developer-guide/end2end-testing.mdx index 0a62251531..9013a32245 100644 --- a/docs/developer-guide/end2end-testing.mdx +++ b/docs/developer-guide/end2end-testing.mdx @@ -221,9 +221,9 @@ Before running E2E tests: ``` - **Ensure Prowler API is available** - - By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). + - By default, Playwright uses `UI_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`). - Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally). - - If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests. + - If a different API URL is required, set `UI_API_BASE_URL` accordingly before running the tests. - **Ensure Prowler App UI is available** - Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default). diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx new file mode 100644 index 0000000000..1f4d17ee2e --- /dev/null +++ b/docs/developer-guide/environment-variables.mdx @@ -0,0 +1,53 @@ +--- +title: 'Environment Variable Naming Convention' +--- + +Prowler is a monorepo composed of several runtime components — Prowler App (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix. + +## Component Prefixes + +Each component owns a dedicated prefix for the environment variables it reads: + +| Component | Prefix | Status | +|-----------|--------|--------| +| Prowler App (web UI) | `UI_` | Adopted | +| Prowler API (backend) | `API_` | Planned | +| Prowler SDK | `SDK_` | Planned | +| Prowler MCP Server | `MCP_` | Planned | + +## Why Component Prefixes Matter + +Component prefixes solve three concrete problems in a shared configuration file: + +- **Collisions in a shared `.env`:** Several components historically read identically named variables. The API base URL, for example, is consumed by more than one component, so a single unprefixed name is ambiguous. A component prefix removes that ambiguity. +- **Explicit ownership:** A prefix states, at a glance, which component consumes a variable. +- **Reduced accidental exposure:** For Prowler App, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle. + +## Prowler App + +Prowler App has adopted the `UI_` prefix. Its public configuration is resolved from the container environment at runtime rather than inlined at build time, so a single pre-built image serves any deployment. For the operational details on changing these values without rebuilding the image, see [Troubleshooting](/troubleshooting). + +The former build-time variables map to the new runtime variables as follows: + +| Former variable | New variable | +|-----------------|--------------| +| `NEXT_PUBLIC_API_BASE_URL` | `UI_API_BASE_URL` | +| `NEXT_PUBLIC_API_DOCS_URL` | `UI_API_DOCS_URL` | +| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` | +| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` | +| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` | + +The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of the App's runtime configuration. + +## Upcoming Breaking Change + + +Adopting the `API_`, `SDK_`, and `MCP_` prefixes for Prowler API, Prowler SDK, and Prowler MCP Server is a planned breaking change in a future release. Migrate environment configuration to the new names when upgrading. + + +Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the convention. In a future release, the variables each of these components reads will be namespaced under `API_`, `SDK_`, and `MCP_` respectively. The per-component mapping from current to prefixed names will be documented when each change is released. + +## Deprecated Names + +- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` build-time variables are deprecated but still read at runtime as a fallback when the matching `UI_*` variable is unset. This fallback will be removed in a future release, so set the `UI_*` runtime variables on the running container. +- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change). diff --git a/docs/developer-guide/introduction.mdx b/docs/developer-guide/introduction.mdx index 117640c669..1d434912ef 100644 --- a/docs/developer-guide/introduction.mdx +++ b/docs/developer-guide/introduction.mdx @@ -108,6 +108,39 @@ uv sync source .venv/bin/activate ``` +### Running the Local API Development Stack + +For API development, Prowler provides a Makefile-based local stack in addition to the manual and Docker Compose workflows documented in the API README. PostgreSQL, Valkey, and Neo4j run with Docker Compose, while Django and the Celery worker run natively through `uv`. + +Before using this method, ensure `docker compose`, `tmux`, and `uv` are installed. + +This workflow is designed for macOS and should also work on Linux when Docker, `tmux`, and `uv` are available. Windows requires script changes before it can be supported. + +To start the local API stack, run: + +```shell +make dev +``` + +This command starts the required services, creates a `tmux` session with panes for the API, worker, and PostgreSQL logs, waits until the API responds, and prints the API URL and log file paths. The API is available at: + +```text +http://localhost:8080/api/v1 +``` + +Use these commands to manage the stack: + +```shell +make dev-setup # Bootstrap dependencies, migrations, and fixtures +make dev-attach # Attach to the tmux session +make dev-launch # Start the stack on fixed ports and attach +make dev-stop # Stop the tmux session and containers +make dev-clean # Remove stopped development containers +make dev-wipe # Stop everything and delete local development data +make dev-status # Show development container status +``` + +The UI is not started by this workflow. Start it separately by following the UI development instructions in the `ui/` directory. ### Pre-Commit Hooks diff --git a/docs/developer-guide/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index 030d876aab..cf756c4da0 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -38,7 +38,7 @@ Before adding a new framework, complete the following checks: - **Verify the framework is not already supported.** Inspect `prowler/compliance/` and every `prowler/compliance//` for an existing JSON file matching the name and version. - **Confirm the required checks exist.** Every requirement that can be automated must point to one or more existing Prowler checks. For each missing check, implement it first by following the [Prowler Checks](/developer-guide/checks) guide. - **Review a reference framework.** Use an existing framework with a similar structure as your template: - - Universal: `prowler/compliance/dora.json`, `prowler/compliance/csa_ccm_4.0.json`. + - Universal: `prowler/compliance/dora_2022_2554.json`, `prowler/compliance/csa_ccm_4.0.json`. - Legacy: `prowler/compliance/aws/cis_2.0_aws.json` (canonical CIS shape), `prowler/compliance/aws/ccc_aws.json`, `prowler/compliance/aws/ens_rd2022_aws.json`, `prowler/compliance/aws/nist_800_53_revision_5_aws.json`. ## Universal Compliance Framework @@ -51,9 +51,9 @@ Place the file at the top level of the compliance directory: prowler/compliance/.json ``` -Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora.json`. +Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora_2022_2554.json`. -The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora.json` → `dora`). +The file is auto-discovered — there is **no** need to register it in any `__init__.py`, modify `prowler/lib/outputs/`, or update any other Python module. The framework key Prowler CLI accepts via `--compliance` is the basename of the JSON file without `.json` (`dora_2022_2554.json` → `dora_2022_2554`). ### Top-level structure @@ -70,7 +70,7 @@ The file is auto-discovered — there is **no** need to register it in any `__in } ``` -A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. +A `provider` field at the top level is **optional**. The framework's effective provider list is derived by `ComplianceFramework.get_providers()` (`compliance_models.py:739`) from the union of all keys appearing in `requirement.checks` across all requirements; the explicit `provider` field is used **only as a fallback** when no requirement carries any `checks` key. This is what enables a single file (e.g. `dora_2022_2554.json`) to cover AWS today and add Azure / GCP / etc. tomorrow without restructuring. Provider keys inside `requirement.checks` must match the directory names under `prowler/providers/`. The valid keys at present are: `aws`, `azure`, `gcp`, `m365`, `kubernetes`, `iac`, `github`, `googleworkspace`, `alibabacloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `oraclecloud`, `llm`. Comparison in `supports_provider()` is case-insensitive, but lowercase is the convention used everywhere in the repository. @@ -493,7 +493,7 @@ Before opening a PR, validate the JSON loads cleanly against the model and that ### 1. Schema validation -For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora.json` that key is `dora`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. +For **universal** frameworks, load the file and inspect what was parsed. The framework key inside `bulk` is the **basename of the JSON file** (without `.json`); for `prowler/compliance/dora_2022_2554.json` that key is `dora_2022_2554`, for `prowler/compliance/aws/cis_5.0_aws.json` it is `cis_5.0_aws`. ```python from prowler.lib.check.compliance_models import ( @@ -619,7 +619,7 @@ The following issues are the most common when contributing a compliance framewor Use the following files as templates when modeling a new contribution. -- `prowler/compliance/dora.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. +- `prowler/compliance/dora_2022_2554.json` — universal schema, single-provider populated (AWS), ready to extend with more providers. - `prowler/compliance/csa_ccm_4.0.json` — universal schema, multi-provider populated (AWS, Azure, GCP, AlibabaCloud, OracleCloud). - `prowler/compliance/aws/cis_2.0_aws.json` — legacy CIS attribute shape. - `prowler/compliance/aws/nist_800_53_revision_5_aws.json` — legacy generic attribute shape. diff --git a/docs/developer-guide/server-sent-events.mdx b/docs/developer-guide/server-sent-events.mdx new file mode 100644 index 0000000000..c91f27227f --- /dev/null +++ b/docs/developer-guide/server-sent-events.mdx @@ -0,0 +1,241 @@ +--- +title: 'Server-Sent Events (SSE)' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +This guide explains how to add a **Server-Sent Events (SSE)** endpoint to the Prowler API. SSE lets the backend push a one-way stream of events to a client over a single long-lived HTTP connection — ideal for live progress, token-by-token LLM output, or any "the server has news for you" use case where the client should not poll. + + +The platform ships the SSE **infrastructure** (`api.sse`) and wiring. No feature endpoint streams over SSE out of the box — this guide shows how to build one on top of the shared base. + + +## When to use SSE + +| Need | Use | +|------|-----| +| Server pushes incremental updates, client only reads | **SSE** | +| Bidirectional, low-latency messaging (chat both ways, games) | WebSocket | +| Client asks, server answers once | Plain REST | + +SSE is the right tool when the **client only consumes**: scan progress, long-running job checkpoints, streamed LLM tokens, cross-client resource-sync notifications. It rides on plain HTTP, reconnects automatically in the browser via the native [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) API, and needs no extra protocol. + +## How it works + +SSE is wired through [`django-eventstream`](https://github.com/fanout/django_eventstream) and a small platform layer in `api/src/backend/api/sse/`: + +| Piece | File | Responsibility | +|-------|------|----------------| +| `BaseSSEViewSet` | `api/sse/base_views.py` | Base DRF viewset a feature subclasses. The feature implements `get_channels`; the base handles auth, the tenant transaction, and delegates streaming to `django-eventstream`. | +| `SSEChannelManager` | `api/sse/channelmanager.py` | Registered in `settings.EVENTSTREAM_CHANNELMANAGER_CLASS`. Reads the channel set off the request and enforces the platform-wide tenant gate. | +| `SSEAuthentication` | `api/authentication.py` | Same JWT/API-key stack as the rest of the API, plus an `?access_token=` fallback for browser `EventSource` clients. Lives with the other authentication classes, not in the `sse` package. | +| `make_channel_name` / `tenant_id_from_channel` | `api/sse/utils.py` | Single source of truth for the channel-name format, so publishers and the channel manager agree byte-for-byte. | +| Settings | `config/settings/eventstream.py` | Valkey Pub/Sub backend (dedicated DB), channel manager, allowed headers. | + +### Transport: the server runs on ASGI + +SSE connections are long-lived. Holding one open per synchronous worker would exhaust the worker pool, so the API runs under Gunicorn's native **`asgi` worker** (`config.asgi:application`). Streams are parked on the event loop while ordinary CRUD endpoints keep their synchronous execution (Django runs sync views in a thread-sensitive executor under ASGI). This is configured in `config/guniconf.py` and used by both the dev and production entrypoints — no separate server process is needed. + +### The data flow + +``` +publisher (Celery task / view) subscriber (browser, CLI) + │ │ + │ send_event(channel, "scan.progress", …) │ GET …/event-stream + ▼ ▼ + Valkey Pub/Sub ◄────────────────────► BaseSSEViewSet.list + (EVENTSTREAM_VALKEY_DB) → get_channels() (RLS-scoped) + → SSEChannelManager (tenant gate) + → StreamingHttpResponse (text/event-stream) +``` + +A publisher anywhere in the system (most often a Celery task) calls `send_event(channel, event_type, payload)`. `django-eventstream` fans it out over Valkey Pub/Sub to every connection subscribed to that channel. + +## Adding an SSE endpoint to your feature + +The example below streams progress for a long-running **scan**. Adapt the resource, prefix, and event names to your feature. + + + + + +Channels follow the format `::`, built only through `make_channel_name`. The prefix is owned by your feature and may contain hyphens but **never colons** (the parser splits on `:`). + +```python +CHANNEL_PREFIX = "scan-progress" +``` + +The tenant id is baked into every channel name. That is what lets the platform enforce cross-tenant isolation without knowing anything about your feature. + + + + + +Create the viewset for the SSE sub-resource. The only required method is `get_channels`; it runs inside the tenant transaction set up by the base class, so any database lookup inside it is automatically RLS-scoped. + +```python +# scans/event_streams.py +from api.sse import BaseSSEViewSet, make_channel_name +from django.shortcuts import get_object_or_404 +from scans.models import Scan + +CHANNEL_PREFIX = "scan-progress" + + +class ScanEventStreamViewSet(BaseSSEViewSet): + def get_queryset(self): + # RLS already scopes to the tenant; narrow further as needed + # (e.g. only scans the requesting user may see). + return Scan.objects.filter(tenant_id=self.request.tenant_id) + + def get_channels(self) -> set[str]: + scan = get_object_or_404(self.get_queryset(), pk=self.kwargs["scan_pk"]) + return {make_channel_name(CHANNEL_PREFIX, scan.tenant_id, scan.id)} +``` + + +`get_channels` **must raise** the relevant DRF exception (`NotFound`, `PermissionDenied`, `NotAuthenticated`) when authorization fails — `get_object_or_404` does this for you. Returning an empty set surfaces as django-eventstream's confusing "No channels specified" error instead of the real cause. + + + + + + +Mount the endpoint as an `event-stream` sub-resource. Keep it **outside the DRF router**, which would force the URL into a list/detail convention. Route the `get` method to the viewset's `list` action. + +```python +# scans/urls.py +path( + "scans//event-stream", + ScanEventStreamViewSet.as_view({"get": "list"}), + name="scan-event-stream", +), +``` + + + + + +A feature owns its event types in `//events.py`: one `publish_` function per event type, each body a **single** `send_event` call so the wire-level string lives in exactly one place. + +```python +# scans/events.py +from django_eventstream import send_event + + +def publish_progress(channel: str, checked: int, total: int) -> None: + send_event(channel, "scan.progress", {"checked": checked, "total": total}) + + +def publish_end(channel: str, scan_id: str) -> None: + # Terminal event carries the canonical id so reconnecting clients + # can refetch the persisted resource over REST. + send_event(channel, "scan.end", {"scan_id": scan_id}) + + +def publish_error(channel: str, code: str, detail: str) -> None: + send_event(channel, "scan.error", {"code": code, "detail": detail}) +``` + +There is no platform-side enum, registry, or dispatch table — **the naming convention is the contract** (see below). + + + + + +Wherever the work happens — usually a Celery task — build the channel the same way and publish: + +```python +from api.sse import make_channel_name +from scans.events import publish_progress, publish_end + +channel = make_channel_name("scan-progress", scan.tenant_id, scan.id) +publish_progress(channel, checked=42, total=100) +... +publish_end(channel, scan_id=str(scan.id)) +``` + + + + + +## Event naming convention + +Every event uses an event type of the form **`.`** (lowercased, dot-separated). The verb comes from this platform-wide vocabulary — if you need a verb that is not listed, document the addition in this guide so the catalog stays discoverable. + +| Verb | When to use | +|------|-------------| +| `delta` | An incremental piece of a stream the client concatenates (LLM text tokens, audio chunks). Standard term across OpenAI / Anthropic / LiteLLM / Vercel AI SDK. | +| `start` | Begin marker for a compound operation (e.g. a tool call whose execution will be reported by a matching `end`). | +| `end` | Terminal marker. Carries the canonical resource id so reconnecting clients can refetch persisted state via REST. | +| `progress` | Periodic checkpoint with quantifiable completion, e.g. `{"checked": 42, "total": 100}`. | +| `created` / `updated` / `deleted` | Resource-lifecycle events for cross-client sync streams. | +| `error` | Terminal failure. Carries a stable `code` for client switching and a human-readable `detail`. | + + +Payloads are **flat JSON**. The wire-level `event:` field already names the event type, so do **not** wrap the payload in `{"type": ..., "data": ...}`. Include the canonical resource UUID on terminal events so reconnecting clients can reconcile via REST. + + +## Authentication + +SSE endpoints use the same authentication stack as the rest of the API. Non-browser clients (CLI, programmatic) send the standard `Authorization` header — JWT or API key. + +Browser `EventSource` is the only widely available SSE client API and it **cannot set custom headers**. For that case only, the endpoint accepts a JWT via the `?access_token=` query parameter. The header always wins when present — a header is intentional, while a query parameter can leak into referers and logs, so it is consulted only as a fallback. + +```javascript +// Browser +const es = new EventSource( + `/api/v1/scans/${scanId}/event-stream?access_token=${jwt}` +); +``` + +```bash +# CLI / programmatic — header, exactly like every other endpoint +curl -N -H "Authorization: Bearer $JWT" \ + https:///api/v1/scans/$SCAN_ID/event-stream +``` + +## Tenant isolation & security model + +Authorization is enforced at two layers: + +1. **At connect**, `get_channels` runs under the regular DRF stack inside the tenant transaction (`rls_transaction`). Resource lookups are RLS-scoped, so a user cannot even resolve a channel for a resource they cannot see. Narrow the queryset further (e.g. `created_by=request.user`) when a resource is per-user within a tenant. +2. **After connect**, `SSEChannelManager.can_read_channel` re-verifies tenant membership by parsing the tenant id embedded in the channel name. Cross-tenant subscription is rejected even if a URL-level check ever has a bug. A malformed channel name is treated as "not authorized". + +Because the tenant id lives inside the channel name, this gate works for any feature without the platform knowing anything about it. + +## Reconnect & state recovery + +The platform deliberately ships **without server-side replay** (`is_channel_reliable` returns `False`). When a client reconnects, it does **not** receive missed events. Instead: + +- Terminal events (`*.end`) carry the canonical resource **UUID**. +- On reconnect, the client refetches the authoritative state from the normal REST endpoint using that id. + +Design your event payloads accordingly: deltas are ephemeral and concatenated in-flight; the durable truth always lives behind a REST resource. + +## Local development + +- The dev and production entrypoints both launch Gunicorn with the `asgi` worker (`config.asgi:application`). In dev, `DJANGO_DEBUG=True` enables hot reload; `preload_app` is automatically disabled under debug so edited code is picked up. +- SSE uses a **dedicated Valkey database** (`EVENTSTREAM_VALKEY_DB`, default `2`) kept separate from the Celery broker so a noisy broker cannot crowd out streaming traffic. It reuses the same `VALKEY_*` connection settings as the rest of the platform. + +| Env var | Default | Purpose | +|---------|---------|---------| +| `EVENTSTREAM_VALKEY_DB` | `2` | Valkey DB index for the SSE Pub/Sub bus | +| `DJANGO_WORKER_CLASS` | `asgi` | Gunicorn worker class | + +Test the stream end to end with `curl -N` (disable buffering) and an auth header: + +```bash +curl -N -H "Authorization: Bearer $JWT" \ + http://localhost:8080/api/v1/scans/$SCAN_ID/event-stream +``` + +## Testing + +The platform basis is covered by `api/tests/test_sse.py` (channel parsing, the tenant gate, and auth precedence). For a feature endpoint, test: + +- `get_channels` returns the expected channel for an authorized resource and raises `NotFound`/`PermissionDenied` otherwise. +- Each `publish_` helper emits the correct event type and flat payload (mock `send_event`). +- The producer builds the channel with `make_channel_name` using the resource's own `tenant_id`. diff --git a/docs/docs.json b/docs/docs.json index e31575878e..5b88b5d2da 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -359,6 +359,13 @@ "user-guide/providers/okta/getting-started-okta", "user-guide/providers/okta/authentication" ] + }, + { + "group": "Linode", + "pages": [ + "user-guide/providers/linode/getting-started-linode", + "user-guide/providers/linode/authentication" + ] } ] }, @@ -395,7 +402,8 @@ "developer-guide/lighthouse-architecture", "developer-guide/mcp-server", "developer-guide/ai-skills", - "developer-guide/prowler-studio" + "developer-guide/prowler-studio", + "developer-guide/server-sent-events" ] }, { @@ -416,6 +424,7 @@ "group": "Miscellaneous", "pages": [ "developer-guide/documentation", + "developer-guide/environment-variables", { "group": "Testing", "pages": [ diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index dea9c09446..eea9075cda 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -128,8 +128,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.29.0" -PROWLER_API_VERSION="5.29.0" +PROWLER_UI_VERSION="5.30.0" +PROWLER_API_VERSION="5.30.0" ``` diff --git a/docs/getting-started/installation/prowler-cli.mdx b/docs/getting-started/installation/prowler-cli.mdx index f87653dcfe..eae3d54ab1 100644 --- a/docs/getting-started/installation/prowler-cli.mdx +++ b/docs/getting-started/installation/prowler-cli.mdx @@ -4,7 +4,7 @@ title: 'Installation' ## Installation -To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): +To install Prowler as a Python package, use `Python >= 3.10, <= 3.13`. Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/): @@ -12,7 +12,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * `pipx` installed: [pipx installation](https://pipx.pypa.io/stable/installation/). * AWS, GCP, Azure and/or Kubernetes credentials @@ -30,7 +30,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * `Python pip >= 21.0.0` * AWS, GCP, Azure, M365 and/or Kubernetes credentials @@ -81,7 +81,7 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Python >= 3.10, <= 3.12` + * `Python >= 3.10, <= 3.13` * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: @@ -96,8 +96,8 @@ To install Prowler as a Python package, use `Python >= 3.10, <= 3.12`. Prowler i _Requirements_: - * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.12` is installed. - * `Python >= 3.10, <= 3.12` + * `Ubuntu 23.04` or above. For older Ubuntu versions, check [pipx installation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#__tabbed_1_1) and ensure `Python >= 3.10, <= 3.13` is installed. + * `Python >= 3.10, <= 3.13` * AWS, GCP, Azure and/or Kubernetes credentials _Commands_: diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 9d60c57321..3125d74ef5 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -2,6 +2,8 @@ title: 'Troubleshooting' --- +import { VersionBadge } from "/snippets/version-badge.mdx" + ## Running `prowler` I get `[File: utils.py:15] [Module: utils] CRITICAL: path/redacted: OSError[13]` That is an error related to file descriptors or opened files allowed by your operating system. @@ -81,6 +83,39 @@ docker compose down docker compose up -d ``` +### Worker Uses Too Much Memory on Hosts with Many CPUs + + + +When Prowler App runs self-hosted on a machine or Kubernetes node with many CPUs, +the Celery worker may create one prefork process per detected CPU if concurrency +is not configured explicitly. Each process loads the SDK runtime and cloud +provider clients, so idle memory can be high and worker containers can be +terminated by their memory limit. + +Set `DJANGO_CELERY_WORKER_CONCURRENCY` in the worker runtime environment to cap +the number of prefork processes: + +```yaml +services: + worker: + environment: + DJANGO_CELERY_WORKER_CONCURRENCY: "4" +``` + +For Kubernetes deployments, set the same variable on the worker Deployment: + +```yaml +env: + - name: DJANGO_CELERY_WORKER_CONCURRENCY + value: "4" +``` + +Lower values reduce idle memory and the number of tasks a worker can run in +parallel. Increase the value only when the worker has enough memory for the +expected scan workload. Leaving the variable unset preserves Celery's default +CPU-based concurrency. + ### API Container Fails to Start with JWT Key Permission Error See [GitHub Issue #8897](https://github.com/prowler-cloud/prowler/issues/8897) for more details. @@ -201,35 +236,29 @@ When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balan **Root Cause:** -Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle. +The API base and docs URLs are resolved from the container environment **at runtime**. A single pre-built Docker image (`prowlercloud/prowler-ui:stable`) therefore serves any environment: point the URLs at your external domain and restart the container — no rebuild is required. **Solution:** -You must **rebuild** the UI Docker image with your external URL: - -```bash -# Clone the repository (if you haven't already) -git clone https://github.com/prowler-cloud/prowler.git -cd prowler/ui - -# Build with your external URL as a build argument -docker build \ - --build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \ - --build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \ - -t prowler-ui-custom:latest \ - --target prod \ - . -``` - -Then update your `docker-compose.yml` to use your custom image instead of the pre-built one: +Set the runtime environment variables to your external URL and restart the UI container: ```yaml services: ui: - image: prowler-ui-custom:latest # Use your custom-built image + image: prowlercloud/prowler-ui:stable + environment: + UI_API_BASE_URL: https://prowler.example.com/api/v1 + UI_API_DOCS_URL: https://prowler.example.com/api/v1/docs # ... rest of configuration ``` +The same values can be supplied through your `.env` file: + +```bash +UI_API_BASE_URL=https://prowler.example.com/api/v1 +UI_API_DOCS_URL=https://prowler.example.com/api/v1/docs +``` + -The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables. +Earlier releases inlined these values into the JavaScript bundle at build time (via the `NEXT_PUBLIC_` prefix) and required a rebuild with `--build-arg`. That is no longer necessary: `UI_API_BASE_URL` and `UI_API_DOCS_URL` are read at container start, so updating them and restarting is sufficient. diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index caf8853a1b..657549f8b0 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -10,14 +10,20 @@ prowler/config/config.yaml Additionally, you can input a custom configuration file using the `--config-file` argument. + +Numeric thresholds enforce hard limits. A value outside the accepted range is dropped with a warning and the check falls back to its built-in default. See [Configuration Value Limits](/developer-guide/configurable-checks#configuration-value-limits) for the exact range of every bounded option (max-days caps, percentages, counts, etc.). + + ## AWS ### Configurable Checks + The following list includes all the AWS checks with configurable variables that can be changed in the configuration yaml file: | Check Name | Value | Type | |---------------------------------------------------------------|--------------------------------------------------|-----------------| | `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer | +| `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings | | `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer | | `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer | | `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer | @@ -55,6 +61,9 @@ The following list includes all the AWS checks with configurable variables that | `elasticache_redis_cluster_backup_enabled` | `minimum_snapshot_retention_period` | Integer | | `elb_is_in_multiple_az` | `elb_min_azs` | Integer | | `elbv2_is_in_multiple_az` | `elbv2_min_azs` | Integer | +| `rolesanywhere_trust_anchor_pqc_pki` | `rolesanywhere_pqc_pca_key_algorithms` | List of Strings | +| `cloudfront_distributions_pqc_tls_enabled` | `cloudfront_pqc_min_protocol_versions` | List of Strings | +| `apigateway_domain_name_pqc_tls_enabled` | `apigateway_pqc_tls_allowed_policies` | List of Strings | | `guardduty_is_enabled` | `mute_non_default_regions` | Boolean | | `iam_user_access_not_stale_to_sagemaker` | `max_unused_sagemaker_access_days` | Integer | | `iam_user_accesskey_unused` | `max_unused_access_keys_days` | Integer | @@ -67,6 +76,7 @@ The following list includes all the AWS checks with configurable variables that | `secretsmanager_secret_rotated_periodically` | `max_days_secret_unrotated` | Integer | | `ssm_document_secrets` | `secrets_ignore_patterns` | List of Strings | | `trustedadvisor_premium_support_plan_subscribed` | `verify_premium_support_plans` | Boolean | +| `transfer_server_pqc_ssh_kex_enabled` | `transfer_pqc_ssh_allowed_policies` | List of Strings | | `dynamodb_table_cross_account_access` | `trusted_account_ids` | List of Strings | | `eventbridge_bus_cross_account_access` | `trusted_account_ids` | List of Strings | | `eventbridge_schema_registry_cross_account_access` | `trusted_account_ids` | List of Strings | diff --git a/docs/user-guide/cookbooks/cicd-pipeline.mdx b/docs/user-guide/cookbooks/cicd-pipeline.mdx index a88513d901..11295b1f29 100644 --- a/docs/user-guide/cookbooks/cicd-pipeline.mdx +++ b/docs/user-guide/cookbooks/cicd-pipeline.mdx @@ -127,7 +127,7 @@ Add the following to `.gitlab-ci.yml`: ```yaml prowler-scan: - image: python:3.12-slim + image: python:3.13-slim stage: test script: - pip install prowler @@ -154,7 +154,7 @@ stages: - security .prowler-base: - image: python:3.12-slim + image: python:3.13-slim stage: security before_script: - pip install prowler diff --git a/docs/user-guide/providers/gcp/authentication.mdx b/docs/user-guide/providers/gcp/authentication.mdx index 9447d73a20..ec53445c84 100644 --- a/docs/user-guide/providers/gcp/authentication.mdx +++ b/docs/user-guide/providers/gcp/authentication.mdx @@ -138,6 +138,10 @@ To keep permissions focused: 4. Continue through the wizard and finish. No principals need to be granted access in step 3 unless you want other identities to impersonate this account. + +To use this service account with `--organization-id`, additionally grant `roles/cloudasset.viewer` at the organization node and enable the Cloud Asset API in the service account's host project. See [Scanning a Specific GCP Organization](./organization). Without these, organization-wide scans silently fall back to listing only the projects accessible to the service account. + + ### Step 3: Generate a JSON Key 1. Open the newly created service account, move to the **Keys** tab, and choose **Add key > Create new key**. diff --git a/docs/user-guide/providers/gcp/organization.mdx b/docs/user-guide/providers/gcp/organization.mdx index 6f4f7658e5..6790be5827 100644 --- a/docs/user-guide/providers/gcp/organization.mdx +++ b/docs/user-guide/providers/gcp/organization.mdx @@ -11,8 +11,19 @@ prowler gcp --organization-id organization-id ``` -Ensure the credentials used have one of the following roles at the organization level: -Cloud Asset Viewer (`roles/cloudasset.viewer`), or Cloud Asset Owner (`roles/cloudasset.owner`). +Ensure the credentials used have one of the following roles bound **at the organization node** (not at a project): Cloud Asset Viewer (`roles/cloudasset.viewer`) or Cloud Asset Owner (`roles/cloudasset.owner`). The role must be bound directly on the organization so the Cloud Asset API can enumerate projects across the whole hierarchy. + +```bash +gcloud organizations add-iam-policy-binding \ + --member="serviceAccount:" \ + --role="roles/cloudasset.viewer" +``` + +The Cloud Asset API (`cloudasset.googleapis.com`) must also be enabled in the project that owns the credentials (the service account's host project, or the quota project for user credentials): + +```bash +gcloud services enable cloudasset.googleapis.com --project +``` diff --git a/docs/user-guide/providers/linode/authentication.mdx b/docs/user-guide/providers/linode/authentication.mdx new file mode 100644 index 0000000000..feac121f40 --- /dev/null +++ b/docs/user-guide/providers/linode/authentication.mdx @@ -0,0 +1,97 @@ +--- +title: "Linode Authentication in Prowler" +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Linode uses a **Personal Access Token** (PAT) for authentication. Prowler reads the token **exclusively** from the `LINODE_TOKEN` environment variable, so the secret is never exposed in shell history or process listings. There are no credential CLI flags. + +## Required Permissions + +Prowler requires read-only access to your Linode account. The following OAuth scopes are needed on the Personal Access Token: + +| Scope | Access | Description | +|-------|--------|-------------| +| `account` | `Read Only` | Required to list users and verify account identity | +| `linodes` | `Read Only` | Required to list instances and their configurations | +| `firewall` | `Read Only` | Required to list firewalls and their rules | + + +Ensure the token has all required scopes. Missing permissions will cause some checks to fail or return incomplete results. + + +--- + +## Personal Access Token + +### Step 1: Create a Personal Access Token + +1. Log into the [Linode Cloud Manager](https://cloud.linode.com). +2. Click on your username in the top-right corner, then select **API Tokens** under the "My Profile" section. +3. Click **Create a Personal Access Token**. +4. Configure the token: + - **Label:** A descriptive name (e.g., "Prowler Security Scanner") + - **Expiry:** Set an appropriate expiration (e.g., 6 months) + - **Permissions:** Set the following scopes to **Read Only**: + - Account + - Linodes + - Firewall + - All other scopes can be set to **No Access** +5. Click **Create Token**. +6. Copy the token immediately — it will not be shown again. + +### Step 2: Configure Authentication + +Set the `LINODE_TOKEN` environment variable: + +```bash +export LINODE_TOKEN="your-personal-access-token" +``` + +Then run Prowler: + +```bash +prowler linode +``` + +--- + +## Verifying Authentication + +To verify that Prowler can connect to your Linode account, run: + +```bash +prowler linode --list-checks +``` + +If authentication succeeds, you will see a list of available checks. If it fails, Prowler will display an error message indicating the credentials issue. + +--- + +## CI/CD Integration + +For automated pipelines, set the token as a secret environment variable: + +**GitHub Actions:** + +```yaml +env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + +steps: + - name: Run Prowler + run: prowler linode +``` + +**GitLab CI:** + +```yaml +variables: + LINODE_TOKEN: $LINODE_TOKEN + +prowler_scan: + script: + - prowler linode +``` diff --git a/docs/user-guide/providers/linode/getting-started-linode.mdx b/docs/user-guide/providers/linode/getting-started-linode.mdx new file mode 100644 index 0000000000..128fdf1c90 --- /dev/null +++ b/docs/user-guide/providers/linode/getting-started-linode.mdx @@ -0,0 +1,61 @@ +--- +title: 'Getting Started With Linode on Prowler' +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Prowler for Linode scans your Linode infrastructure for security misconfigurations, including compute settings, networking rules, user account security, and more. + + +Linode support in Prowler is community-maintained. For commercial support or to request additional service coverage, [contact us](https://prowler.com/contact). + + +## Prerequisites + +Set up authentication for Linode with the [Linode Authentication](/user-guide/providers/linode/authentication) guide before starting: + +- Create a Linode Personal Access Token with read-only permissions +- The token requires at minimum: `account:read_only`, `linodes:read_only`, and `firewall:read_only` scopes + +## Prowler CLI + +### Run Prowler for Linode + +Once authenticated with a Personal Access Token, set the `LINODE_TOKEN` environment variable and run Prowler for Linode. Prowler reads the token exclusively from the environment variable, so the secret is never exposed in shell history or process listings: + +```bash +export LINODE_TOKEN="your-personal-access-token" +prowler linode +``` + +### Run Specific Checks + +```bash +prowler linode --checks compute_instance_backups_enabled compute_instance_watchdog_enabled +``` + +### Run a Specific Service + +```bash +prowler linode --services networking +``` + +### Scan Specific Regions + +Use `--region` (alias `--filter-region` / `-f`) to limit the scan to one or more Linode regions. Region-less resources (account administration and Cloud Firewalls) are always scanned; only regional resources such as instances are filtered. When the flag is omitted, all regions are scanned. + +```bash +prowler linode --region eu-central us-east +``` + +## Available Services + +Prowler for Linode currently supports the following services: + +| Service | Description | +|---------|-------------| +| `administration` | Account administration includes users and access controls such as two-factor authentication | +| `compute` | Compute includes Linode instances and their workload configuration | +| `networking` | Networking includes Cloud Firewalls and their stateful network rules | diff --git a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx index 2a349c0204..2f10d443ae 100644 --- a/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso-google-workspace.mdx @@ -108,10 +108,10 @@ Prowler App updates user attributes each time a user logs in. Any changes made i The `userType` attribute controls which Prowler role is assigned to the user: - If `userType` matches an existing Prowler role name, the user receives that role automatically. -- If `userType` does not match any existing role, Prowler App creates a new role with that name **without permissions**. -- If `userType` is not set, the user receives the `no_permissions` role. +- If `userType` does not match any existing role, Prowler App creates a new role with that name **with read-only access** (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions afterward through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. +- If `userType` is not set, the user's existing roles are left unchanged. -In all cases where the resulting role has no permissions, a Prowler administrator must configure the appropriate permissions through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles. +The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles. @@ -223,9 +223,9 @@ To test the `userType` → role mapping, set the **Department** attribute in the After a successful SSO login, the user profile in Prowler App reflects the attributes sent by Google Workspace: - **Name**: Populated from the `firstName` and `lastName` attributes. -- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with no permissions by default. -- **Permissions**: In the screenshot below, the user has no permissions because the `Backend` role did not exist prior to login and was created automatically without any permissions. To resolve this, a Prowler administrator can either: - - Assign the appropriate permissions to the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. +- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with read-only access by default. +- **Permissions**: If the assigned permissions need to be adjusted, a Prowler administrator can either: + - Edit the permissions of the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. - Set the `userType` attribute in the IdP to match an existing Prowler role that already has the desired permissions. The updated role is applied on the next SAML login. For more details on role assignment behavior and attribute mapping, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#configure-attribute-mapping-in-the-idp) page. diff --git a/docs/user-guide/tutorials/prowler-app-sso.mdx b/docs/user-guide/tutorials/prowler-app-sso.mdx index d4d2c18d11..1e61ec1060 100644 --- a/docs/user-guide/tutorials/prowler-app-sso.mdx +++ b/docs/user-guide/tutorials/prowler-app-sso.mdx @@ -87,7 +87,7 @@ Choose a Method: |----------------|---------------------------------------------------------------------------------------------------------|----------| | `firstName` | The user's first name. | Yes | | `lastName` | The user's last name. | Yes | - | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name without permissions. If `userType` is not defined, the user is assigned the `no_permissions` role. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | + | `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name with read-only access (visibility over all providers, no management permissions). If `userType` is not defined, the user's existing roles are left unchanged. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No | | `organization` | The user's company name. | No | @@ -140,7 +140,7 @@ Choose a Method: ![Okta User Profile — First Name and Last Name](/images/prowler-app/saml/okta-user-profile-name.png) * **Organization** (`organization`): Maps to the company name displayed in Prowler App. This attribute is optional. - * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive** and must match the exact name of an existing role in Prowler App. + * **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive**: if it matches the exact name of an existing role in Prowler App the user receives that role; if no role with that name exists, a new one is created with read-only access. ![Okta User Profile — User Type and Organization](/images/prowler-app/saml/okta-user-profile-attributes.png) @@ -152,14 +152,10 @@ Choose a Method: The `userType` attribute controls which Prowler role is assigned to the user: * If a role with the specified name already exists in Prowler App, the user automatically receives that role. - * If the role does not exist, Prowler App creates a new role with that exact name but without any permissions, preventing the user from performing any actions. - * If `userType` is not defined in the user's Okta profile, the user is assigned the `no_permissions` role. + * If the role does not exist, Prowler App creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). + * If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler App are left unchanged. - In all cases where the resulting role has no permissions, a Prowler administrator (a user whose role includes the "Manage Account" permission) must configure the appropriate permissions through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). - - This behavior is intentional: by defaulting to no permissions, Prowler App ensures that a misconfiguration in Okta cannot inadvertently grant elevated access. - - **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` without permissions, and a Prowler administrator must configure the desired permissions for it. + **Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed. diff --git a/mcp_server/Dockerfile b/mcp_server/Dockerfile index d8377a1762..c2759b20de 100644 --- a/mcp_server/Dockerfile +++ b/mcp_server/Dockerfile @@ -1,7 +1,7 @@ # ============================================================================= # Build stage - Install dependencies and build the application # ============================================================================= -FROM ghcr.io/astral-sh/uv:python3.13-alpine@sha256:8f53782bb232ab0b5558f3071e86e2bbfde884e18815f2b19cc57f2d336e9ee2 AS builder +FROM ghcr.io/astral-sh/uv:0.11.21-python3.13-alpine3.23@sha256:f09cc61ffc001f202701fdeae14dbdd50f6ca4cfcf248f41fd3234a302c8534f AS builder WORKDIR /app @@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ # ============================================================================= # Final stage - Minimal runtime environment # ============================================================================= -FROM python:3.13-alpine@sha256:bb1f2fdb1065c85468775c9d680dcd344f6442a2d1181ef7916b60a623f11d40 +FROM python:3.13.14-alpine3.23@sha256:b0513989fa9be54569cac73f48a60320b74bb0f9ffa886568eea7e48a2432c04 LABEL maintainer="https://github.com/prowler-cloud" diff --git a/mcp_server/uv.lock b/mcp_server/uv.lock index fd0a9349f9..14b8dcf4f3 100644 --- a/mcp_server/uv.lock +++ b/mcp_server/uv.lock @@ -857,11 +857,11 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -1132,15 +1132,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] diff --git a/permissions/prowler-additions-policy.json b/permissions/prowler-additions-policy.json index 906f06fbfb..c0da045603 100644 --- a/permissions/prowler-additions-policy.json +++ b/permissions/prowler-additions-policy.json @@ -36,6 +36,8 @@ "lightsail:GetRelationalDatabases", "macie2:GetMacieSession", "macie2:GetAutomatedDiscoveryConfiguration", + "rolesanywhere:ListTagsForResource", + "rolesanywhere:ListTrustAnchors", "s3:GetAccountPublicAccessBlock", "shield:DescribeProtection", "shield:GetSubscriptionState", @@ -61,7 +63,9 @@ ], "Resource": [ "arn:*:apigateway:*::/restapis/*", - "arn:*:apigateway:*::/apis/*" + "arn:*:apigateway:*::/apis/*", + "arn:*:apigateway:*::/domainnames", + "arn:*:apigateway:*::/domainnames/*" ], "Sid": "AllowAPIGatewayReadOnly" } diff --git a/permissions/templates/cloudformation/prowler-scan-role.yml b/permissions/templates/cloudformation/prowler-scan-role.yml index 395fed6424..a7d91b0071 100644 --- a/permissions/templates/cloudformation/prowler-scan-role.yml +++ b/permissions/templates/cloudformation/prowler-scan-role.yml @@ -129,6 +129,8 @@ Resources: - "lightsail:GetRelationalDatabases" - "macie2:GetMacieSession" - "macie2:GetAutomatedDiscoveryConfiguration" + - "rolesanywhere:ListTagsForResource" + - "rolesanywhere:ListTrustAnchors" - "s3:GetAccountPublicAccessBlock" - "shield:DescribeProtection" - "shield:GetSubscriptionState" @@ -150,6 +152,8 @@ Resources: Resource: - "arn:*:apigateway:*::/restapis/*" - "arn:*:apigateway:*::/apis/*" + - "arn:*:apigateway:*::/domainnames" + - "arn:*:apigateway:*::/domainnames/*" - !If - OrganizationsEnabled - PolicyName: ProwlerOrganizations diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 451b4aff2d..3cab5e423c 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,10 +2,98 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.30.0] (Prowler UNRELEASED) +## [5.31.0] (Prowler UNRELEASED) ### 🚀 Added +- Support for Python 3.13 [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) +- `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) +- `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211) +- `cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover [(#11024)](https://github.com/prowler-cloud/prowler/pull/11024) +- `cloudfunction_function_inside_vpc` check for GCP provider, verifying Cloud Functions have a Serverless VPC Access connector for private egress [(#11021)](https://github.com/prowler-cloud/prowler/pull/11021) +- `cloudfunction_function_not_publicly_accessible` check for GCP provider, detecting Cloud Functions with `allUsers` or `allAuthenticatedUsers` IAM invocation bindings [(#11022)](https://github.com/prowler-cloud/prowler/pull/11022) +- `secretmanager_secret_not_publicly_accessible` check for GCP provider, detecting Secret Manager secrets with public IAM bindings [(#11025)](https://github.com/prowler-cloud/prowler/pull/11025) +- `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#11523)](https://github.com/prowler-cloud/prowler/pull/11523) +- `cosmosdb_account_automatic_failover_enabled` check for Azure provider [(#11031)](https://github.com/prowler-cloud/prowler/pull/11031) +- `cosmosdb_account_backup_policy_continuous` check for Azure provider [(#11032)](https://github.com/prowler-cloud/prowler/pull/11032) +- `cosmosdb_account_minimum_tls_version` check for Azure provider, verifying Cosmos DB accounts enforce TLS 1.2 or higher for client connections [(#11033)](https://github.com/prowler-cloud/prowler/pull/11033) +- `cosmosdb_account_public_network_access_disabled` check for Azure provider, verifying Cosmos DB accounts have public network access disabled so connectivity is restricted to private endpoints or VNet service endpoints [(#11034)](https://github.com/prowler-cloud/prowler/pull/11034) +- `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035) +- `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036) +- `defender_ensure_defender_cspm_is_on` check for Azure provider, verifying Microsoft Defender Cloud Security Posture Management (CSPM) is enabled on the Standard tier [(#11037)](https://github.com/prowler-cloud/prowler/pull/11037) +- `mysql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying MySQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11041)](https://github.com/prowler-cloud/prowler/pull/11041) +- `mysql_flexible_server_high_availability_enabled` check for Azure provider, verifying MySQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11042)](https://github.com/prowler-cloud/prowler/pull/11042) +- `postgresql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045) +- `postgresql_flexible_server_high_availability_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11046)](https://github.com/prowler-cloud/prowler/pull/11046) +- `aks_cluster_azure_monitor_enabled` check for Azure provider, verifying AKS clusters have Azure Monitor (Container Insights) enabled for metrics, logs, and alerting [(#11029)](https://github.com/prowler-cloud/prowler/pull/11029) +- `aks_cluster_local_accounts_disabled` check for Azure provider, verifying AKS clusters have local accounts disabled so authentication is forced through Microsoft Entra ID [(#11030)](https://github.com/prowler-cloud/prowler/pull/11030) +- `network_subnet_nsg_associated` check for Azure provider, verifying virtual network subnets have a network security group associated to enforce traffic filtering [(#11043)](https://github.com/prowler-cloud/prowler/pull/11043) +- `network_vnet_ddos_protection_enabled` check for Azure provider, verifying virtual networks have Azure DDoS Network Protection enabled [(#11044)](https://github.com/prowler-cloud/prowler/pull/11044) +- `entra_app_registration_credential_not_expired` check for Azure provider, verifying Entra ID app registration secrets and certificates are not expired, expiring within 30 days, or without an expiration date [(#11038)](https://github.com/prowler-cloud/prowler/pull/11038) +- `entra_authentication_methods_policy_strong_auth_enforced` check for Azure provider, verifying the Entra ID authentication methods policy enforces MFA registration and enables at least one strong method (Microsoft Authenticator, FIDO2, or X.509 certificate) [(#11039)](https://github.com/prowler-cloud/prowler/pull/11039) +- `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) +- Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) +- Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) +- TLS certificate verification in the `codepipeline_project_repo_private` check, which previously used an unverified SSL context, leaving the repository-visibility probe open to MITM tampering [(#11603)](https://github.com/prowler-cloud/prowler/pull/11603) +- Support for Linode cloud provider, with compute, networking and administration services [(#11633)](https://github.com/prowler-cloud/prowler/pull/11633) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Azure provider, mapping existing Azure checks across the five DORA pillars [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- Rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) +- `entra_directory_sync_object_takeover_blocked` check for the M365 provider, verifying that hybrid Entra tenants block cloud object takeover through both soft-match and hard-match directory synchronization [(#11098)](https://github.com/prowler-cloud/prowler/pull/11098) +- `entra_conditional_access_policy_no_deleted_object_references` check for M365 provider [(#11236)](https://github.com/prowler-cloud/prowler/pull/11236) +- `aks_cluster_defender_enabled` check for Azure provider, verifying that AKS clusters have Microsoft Defender security monitoring enabled [(#11028)](https://github.com/prowler-cloud/prowler/pull/11028) +- `recovery_vault_has_protected_items` check for Azure provider, verifying that Recovery Services vaults have at least one protected backup item [(#11048)](https://github.com/prowler-cloud/prowler/pull/11048) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the GCP provider, mapping existing GCP checks across the five DORA pillars [(#11642)](https://github.com/prowler-cloud/prowler/pull/11642) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the Cloudflare provider, mapping existing Cloudflare edge/network checks across the applicable DORA pillars [(#11645)](https://github.com/prowler-cloud/prowler/pull/11645) +- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) compliance coverage for the AlibabaCloud provider, mapping existing AlibabaCloud checks across the applicable DORA pillars [(#11646)](https://github.com/prowler-cloud/prowler/pull/11646) +- `cloudfront_distributions_pqc_tls_enabled` check for AWS provider to verify CloudFront distributions enforce a post-quantum TLS 1.3 security policy [(#11317)](https://github.com/prowler-cloud/prowler/pull/11317) +- `apigateway_domain_name_pqc_tls_enabled` check for AWS provider to verify API Gateway custom domain names use a post-quantum TLS security policy [(#11316)](https://github.com/prowler-cloud/prowler/pull/11316) +- `transfer_server_pqc_ssh_kex_enabled` check for AWS provider to verify Transfer Family servers use a post-quantum hybrid SSH key exchange security policy [(#11315)](https://github.com/prowler-cloud/prowler/pull/11315) +- `acmpca_certificate_authority_pqc_key_algorithm` check and new `acmpca` service for AWS provider to verify AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm [(#11318)](https://github.com/prowler-cloud/prowler/pull/11318) +- `rolesanywhere_trust_anchor_pqc_pki` check and new `rolesanywhere` service for AWS provider to verify IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI [(#11319)](https://github.com/prowler-cloud/prowler/pull/11319) +- Kubernetes core checks for container CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes [(#11373)](https://github.com/prowler-cloud/prowler/pull/11373) + +### 🔄 Changed + +- Replaced the unmaintained `awsipranges` dependency with a small standard-library helper for the `route53_dangling_ip_subdomain_takeover` check [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) + +### 🐞 Fixed + +- Azure PostgreSQL flexible server inventory no longer aborts the whole subscription when the `connection_throttle.enable` parameter is missing (e.g. PostgreSQL v18), and logs the expected "Entra ID authentication not enabled" case as a warning instead of an error, so servers are still scanned [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045) +- `iam_policy_allows_privilege_escalation` now includes the `privilege-escalation` category [(#11648)](https://github.com/prowler-cloud/prowler/pull/11648) + +### 🔐 Security + +- `pytest` from 8.3.5 to 9.0.3, patching a known vulnerability in the SDK test dependency [(#11291)](https://github.com/prowler-cloud/prowler/pull/11291) +- `black` from 25.1.0 to 26.3.1, patching a known vulnerability in the SDK formatter dependency [(#11290)](https://github.com/prowler-cloud/prowler/pull/11290) +- `microsoft-kiota-*` to 1.9.9 and `aiohttp` to 3.14.0, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- Container base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` bumped to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs); `.trivyignore` documents remaining bookworm criticals with no-fix or not-affected rationale [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592) + +--- + +## [5.30.3] (Prowler v5.30.3) + +### 🐞 Fixed + +- CLI compliance summary tables no longer undercount findings mapped to multiple sections nor double-count a single finding mapped to several requirements within the same group/split, and the Provider column no longer leaks a value from another framework [(#11567)](https://github.com/prowler-cloud/prowler/pull/11567) + +--- + +## [5.30.2] (Prowler v5.30.2) + +### 🐞 Fixed + +- GCP `logging_log_metric_filter_and_alert_*` checks now credit org-level aggregated sinks filtered to the Admin Activity audit stream [(#11575)](https://github.com/prowler-cloud/prowler/pull/11575) +- A broken built-in provider no longer aborts the CLI when a different provider was invoked [(#11618)](https://github.com/prowler-cloud/prowler/pull/11618) +- GCP organization scans with `--organization-id` no longer silently fall back to the credentials' host project when the Cloud Asset API call fails [(#11280)](https://github.com/prowler-cloud/prowler/pull/11280) + +--- + +## [5.30.0] (Prowler v5.30.0) + +### 🚀 Added + +- DISA Okta IDaaS STIG V1R2 compliance framework for the Okta provider, with a dedicated CSV output formatter and terminal summary table [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) - `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278) - DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) - Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465) @@ -19,6 +107,8 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070) - `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) - `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215) +- `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335) +- STACKIT ObjectStorage service with Object Lock, default retention policy, and access key expiration checks [(#11397)](https://github.com/prowler-cloud/prowler/pull/11397) ### 🐞 Fixed @@ -26,6 +116,12 @@ All notable changes to the **Prowler SDK** are documented in this file. - `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511) - M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510) - GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516) +- AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345) +- AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526) + +### 🔐 Security + +- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) --- @@ -38,10 +134,6 @@ All notable changes to the **Prowler SDK** are documented in this file. - Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474) - GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467) -### 🔐 Security - -- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) - --- ## [5.29.1] (Prowler v5.29.1) diff --git a/prowler/__main__.py b/prowler/__main__.py index 6cbaf575d9..d4c925f74f 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -19,7 +19,7 @@ from prowler.config.config import ( orange_color, sarif_file_suffix, ) -from prowler.lib.banner import print_banner +from prowler.lib.banner import print_banner, print_prowler_cloud_banner from prowler.lib.check.check import ( exclude_checks_to_run, exclude_services_to_run, @@ -102,6 +102,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import ( AzureMitreAttack, ) from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import ( ProwlerThreatScoreAlibaba, ) @@ -144,6 +147,7 @@ from prowler.providers.iac.models import IACOutputOptions from prowler.providers.image.exceptions.exceptions import ImageBaseException from prowler.providers.image.models import ImageOutputOptions from prowler.providers.kubernetes.models import KubernetesOutputOptions +from prowler.providers.linode.models import LinodeOutputOptions from prowler.providers.llm.models import LLMOutputOptions from prowler.providers.m365.models import M365OutputOptions from prowler.providers.mongodbatlas.models import MongoDBAtlasOutputOptions @@ -199,7 +203,7 @@ def prowler(): if not args.no_banner: legend = args.verbose or getattr(args, "fixer", None) - print_banner(legend) + print_banner(legend, provider) # We treat the compliance framework as another output format if compliance_framework: @@ -436,6 +440,10 @@ def prowler(): output_options = ScalewayOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "linode": + output_options = LinodeOutputOptions( + args, bulk_checks_metadata, global_provider.identity + ) else: # Dynamic fallback: any external/custom provider try: @@ -1314,6 +1322,33 @@ def prowler(): ) generated_outputs["compliance"].append(generic_compliance) generic_compliance.batch_write_data_to_file() + elif provider == "okta": + for compliance_name in input_compliance_frameworks: + if compliance_name.startswith("okta_idaas_stig"): + # Generate Okta IDaaS STIG Finding Object + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + okta_idaas_stig = OktaIDaaSSTIG( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(okta_idaas_stig) + okta_idaas_stig.batch_write_data_to_file() + else: + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + generic_compliance = GenericCompliance( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(generic_compliance) + generic_compliance.batch_write_data_to_file() else: # Dynamic fallback: any external/custom provider try: @@ -1446,6 +1481,10 @@ def prowler(): f"\nDetailed compliance results are in {Fore.YELLOW}{output_options.output_directory}/compliance/{Style.RESET_ALL}\n" ) + # Promote Prowler Cloud as the last thing the user sees after the results + if not args.no_banner and not args.only_logs: + print_prowler_cloud_banner(provider) + # If custom checks were passed, remove the modules if checks_folder: remove_custom_checks_module(checks_folder, provider) diff --git a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json index abd4b61fd0..a025bb3a3c 100644 --- a/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json +++ b/prowler/compliance/aws/aws_well_architected_framework_security_pillar_aws.json @@ -1181,6 +1181,9 @@ "elb_insecure_ssl_ciphers", "elb_ssl_listeners", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_ssl_listeners", "s3_bucket_secure_transport_policy" ] diff --git a/prowler/compliance/aws/ccc_aws.json b/prowler/compliance/aws/ccc_aws.json index f6f3361fbe..7935424193 100644 --- a/prowler/compliance/aws/ccc_aws.json +++ b/prowler/compliance/aws/ccc_aws.json @@ -49,6 +49,9 @@ "elb_insecure_ssl_ciphers", "elb_ssl_listeners", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_ssl_listeners", "elbv2_nlb_tls_termination_enabled", "s3_bucket_secure_transport_policy", diff --git a/prowler/compliance/aws/ens_rd2022_aws.json b/prowler/compliance/aws/ens_rd2022_aws.json index 8f8c912780..144437ce52 100644 --- a/prowler/compliance/aws/ens_rd2022_aws.json +++ b/prowler/compliance/aws/ens_rd2022_aws.json @@ -2494,7 +2494,10 @@ } ], "Checks": [ - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { @@ -2517,7 +2520,10 @@ } ], "Checks": [ - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { diff --git a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json index 5ac74662be..eaa3ea25dc 100644 --- a/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json +++ b/prowler/compliance/aws/fedramp_moderate_revision_4_aws.json @@ -1377,6 +1377,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" @@ -1396,6 +1399,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" diff --git a/prowler/compliance/aws/ffiec_aws.json b/prowler/compliance/aws/ffiec_aws.json index 8668c8ef2e..8a50b79925 100644 --- a/prowler/compliance/aws/ffiec_aws.json +++ b/prowler/compliance/aws/ffiec_aws.json @@ -615,6 +615,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "s3_bucket_secure_transport_policy" ] diff --git a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json index 94d9eb6f76..871af9e726 100644 --- a/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json +++ b/prowler/compliance/aws/gxp_21_cfr_part_11_aws.json @@ -266,6 +266,9 @@ "ec2_ebs_default_encryption", "efs_encryption_at_rest_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_encryption_at_rest_enabled", "opensearch_service_domains_node_to_node_encryption_enabled", diff --git a/prowler/compliance/aws/iso27001_2013_aws.json b/prowler/compliance/aws/iso27001_2013_aws.json index 1a81a283fd..1de8c23db8 100644 --- a/prowler/compliance/aws/iso27001_2013_aws.json +++ b/prowler/compliance/aws/iso27001_2013_aws.json @@ -35,7 +35,10 @@ ], "Checks": [ "elb_insecure_ssl_ciphers", - "elbv2_insecure_ssl_ciphers" + "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled" ] }, { diff --git a/prowler/compliance/aws/kisa_isms_p_2023_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_aws.json index 56ac227a1d..7b0446ac3f 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_aws.json @@ -2064,6 +2064,9 @@ "elb_ssl_listeners", "elb_ssl_listeners_use_acm_certificate", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_nlb_tls_termination_enabled", "elbv2_ssl_listeners", "glue_data_catalogs_connection_passwords_encryption_enabled", @@ -3139,6 +3142,9 @@ "elb_ssl_listeners_use_acm_certificate", "elbv2_desync_mitigation_mode", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_internet_facing", "elbv2_listeners_underneath", "elbv2_logging_enabled", diff --git a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json index e2344a1cee..40b338ce41 100644 --- a/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json +++ b/prowler/compliance/aws/kisa_isms_p_2023_korean_aws.json @@ -2066,6 +2066,9 @@ "elb_ssl_listeners", "elb_ssl_listeners_use_acm_certificate", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_nlb_tls_termination_enabled", "elbv2_ssl_listeners", "glue_data_catalogs_connection_passwords_encryption_enabled", @@ -3142,6 +3145,9 @@ "elb_ssl_listeners_use_acm_certificate", "elbv2_desync_mitigation_mode", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elbv2_internet_facing", "elbv2_listeners_underneath", "elbv2_logging_enabled", diff --git a/prowler/compliance/aws/nist_800_171_revision_2_aws.json b/prowler/compliance/aws/nist_800_171_revision_2_aws.json index 2092e9d02c..921bd33a53 100644 --- a/prowler/compliance/aws/nist_800_171_revision_2_aws.json +++ b/prowler/compliance/aws/nist_800_171_revision_2_aws.json @@ -717,6 +717,9 @@ "apigateway_restapi_client_certificate_enabled", "ec2_ebs_volume_encryption", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_default_encryption", "s3_bucket_secure_transport_policy" diff --git a/prowler/compliance/aws/nist_800_53_revision_5_aws.json b/prowler/compliance/aws/nist_800_53_revision_5_aws.json index c3fde656c4..e0ef936229 100644 --- a/prowler/compliance/aws/nist_800_53_revision_5_aws.json +++ b/prowler/compliance/aws/nist_800_53_revision_5_aws.json @@ -5592,6 +5592,9 @@ "Checks": [ "apigateway_restapi_client_certificate_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "opensearch_service_domains_node_to_node_encryption_enabled", "s3_bucket_secure_transport_policy" @@ -5879,6 +5882,9 @@ ], "Checks": [ "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners" ] }, diff --git a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json index b292e7d411..5de1f5ca8a 100644 --- a/prowler/compliance/aws/rbi_cyber_security_framework_aws.json +++ b/prowler/compliance/aws/rbi_cyber_security_framework_aws.json @@ -40,6 +40,9 @@ "ec2_instance_public_ip", "efs_encryption_at_rest_enabled", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "elb_ssl_listeners", "ec2_ebs_default_encryption", "emr_cluster_master_nodes_no_public_ip", diff --git a/prowler/compliance/aws/secnumcloud_3.2_aws.json b/prowler/compliance/aws/secnumcloud_3.2_aws.json index 3115dbb581..701f931b05 100644 --- a/prowler/compliance/aws/secnumcloud_3.2_aws.json +++ b/prowler/compliance/aws/secnumcloud_3.2_aws.json @@ -490,6 +490,9 @@ "elbv2_ssl_listeners", "elb_insecure_ssl_ciphers", "elbv2_insecure_ssl_ciphers", + "cloudfront_distributions_pqc_tls_enabled", + "apigateway_domain_name_pqc_tls_enabled", + "transfer_server_pqc_ssh_kex_enabled", "redshift_cluster_in_transit_encryption_enabled", "elasticache_redis_cluster_in_transit_encryption_enabled", "dynamodb_accelerator_cluster_in_transit_encryption_enabled", diff --git a/prowler/compliance/azure/cis_2.0_azure.json b/prowler/compliance/azure/cis_2.0_azure.json index 73f4dc3611..59bd153352 100644 --- a/prowler/compliance/azure/cis_2.0_azure.json +++ b/prowler/compliance/azure/cis_2.0_azure.json @@ -2714,7 +2714,8 @@ "Id": "6.6", "Description": "Ensure that Network Watcher is 'Enabled'", "Checks": [ - "network_watcher_enabled" + "network_watcher_enabled", + "network_vnet_ddos_protection_enabled" ], "Attributes": [ { diff --git a/prowler/compliance/azure/cis_5.0_azure.json b/prowler/compliance/azure/cis_5.0_azure.json index da76891b20..dce5f299fe 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -1974,7 +1974,9 @@ { "Id": "7.11", "Description": "Ensure subnets are associated with network security groups", - "Checks": [], + "Checks": [ + "network_subnet_nsg_associated" + ], "Attributes": [ { "Section": "7 Networking Services", @@ -2094,7 +2096,9 @@ { "Id": "8.1.1.1", "Description": "Ensure Microsoft Defender CSPM is set to 'On'", - "Checks": [], + "Checks": [ + "defender_ensure_defender_cspm_is_on" + ], "Attributes": [ { "Section": "8 Security Services", @@ -2931,7 +2935,9 @@ { "Id": "8.5", "Description": "Ensure Azure DDoS Network Protection is enabled on virtual networks", - "Checks": [], + "Checks": [ + "network_vnet_ddos_protection_enabled" + ], "Attributes": [ { "Section": "8 Security Services", diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 3f525332d2..4365b8a6b9 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -438,7 +438,10 @@ "storage_geo_redundant_enabled", "keyvault_recoverable", "sqlserver_auditing_retention_90_days", - "postgresql_flexible_server_log_retention_days_greater_3" + "postgresql_flexible_server_log_retention_days_greater_3", + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled" ] }, { diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 0051795890..ab17f7057c 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -271,6 +271,7 @@ } ], "Checks": [ + "aks_cluster_local_accounts_disabled", "defender_ensure_defender_for_containers_is_on", "defender_ensure_defender_for_cosmosdb_is_on", "defender_ensure_defender_for_databases_is_on", @@ -1102,6 +1103,7 @@ } ], "Checks": [ + "entra_authentication_methods_policy_strong_auth_enforced", "entra_conditional_access_policy_require_mfa_for_management_app", "entra_non_privileged_user_has_mfa entra_privileged_user_has_mfa", "entra_user_with_vm_access_has_mfa", @@ -1266,7 +1268,11 @@ "Check_Summary": "Backup copies of information, software and systems should be maintained and regularly tested in accordance with the agreed topic-specific policy on backup." } ], - "Checks": [] + "Checks": [ + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled" + ] }, { "Id": "A.8.14", @@ -1293,7 +1299,12 @@ "storage_ensure_private_endpoints_in_storage_accounts", "storage_secure_transfer_required_is_enabled", "vm_ensure_using_managed_disks", - "vm_trusted_launch_enabled" + "vm_trusted_launch_enabled", + "cosmosdb_account_automatic_failover_enabled", + "mysql_flexible_server_geo_redundant_backup_enabled", + "mysql_flexible_server_high_availability_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_high_availability_enabled" ] }, { @@ -1337,6 +1348,8 @@ } ], "Checks": [ + "aks_cluster_azure_monitor_enabled", + "defender_ensure_defender_cspm_is_on", "monitor_alert_create_policy_assignment", "monitor_alert_create_update_nsg", "monitor_alert_create_update_public_ip_address_rule", @@ -1407,6 +1420,9 @@ "aks_network_policy_enabled", "containerregistry_not_publicly_accessible", "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_no_public_ip_enabled", + "databricks_workspace_public_network_access_disabled", "network_bastion_host_exists", "network_flow_log_captured_sent", "network_flow_log_more_than_90_days", @@ -1414,7 +1430,9 @@ "network_public_ip_shodan", "network_rdp_internet_access_restricted", "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", "network_udp_internet_access_restricted", + "network_vnet_ddos_protection_enabled", "network_watcher_enabled" ] }, @@ -1468,6 +1486,7 @@ "network_public_ip_shodan", "network_rdp_internet_access_restricted", "network_ssh_internet_access_restricted", + "network_subnet_nsg_associated", "network_udp_internet_access_restricted", "network_watcher_enabled" ] @@ -1500,6 +1519,8 @@ ], "Checks": [ "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", + "entra_app_registration_credential_not_expired", "monitor_storage_account_with_activity_logs_cmk_encrypted", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled", diff --git a/prowler/compliance/azure/mitre_attack_azure.json b/prowler/compliance/azure/mitre_attack_azure.json index 92ddd72713..8b338da534 100644 --- a/prowler/compliance/azure/mitre_attack_azure.json +++ b/prowler/compliance/azure/mitre_attack_azure.json @@ -212,6 +212,7 @@ "Description": "Adversaries may obtain and abuse credentials of existing accounts as a means of gaining Initial Access, Persistence, Privilege Escalation, or Defense Evasion. Compromised credentials may be used to bypass access controls placed on various resources on systems within the network and may even be used for persistent access to remote systems and externally available services, such as VPNs, Outlook Web Access, network devices, and remote desktop.[1] Compromised credentials may also grant an adversary increased privilege to specific systems or access to restricted areas of the network. Adversaries may choose not to use malware or tools in conjunction with the legitimate access those credentials provide to make it harder to detect their presence.", "TechniqueURL": "https://attack.mitre.org/techniques/T1078/", "Checks": [ + "entra_app_registration_credential_not_expired", "entra_conditional_access_policy_require_mfa_for_management_api", "entra_global_admin_in_less_than_five_users", "entra_non_privileged_user_has_mfa", diff --git a/prowler/compliance/azure/secnumcloud_3.2_azure.json b/prowler/compliance/azure/secnumcloud_3.2_azure.json index 100181c931..3f2e8c7603 100644 --- a/prowler/compliance/azure/secnumcloud_3.2_azure.json +++ b/prowler/compliance/azure/secnumcloud_3.2_azure.json @@ -339,6 +339,7 @@ } ], "Checks": [ + "entra_authentication_methods_policy_strong_auth_enforced", "entra_non_privileged_user_has_mfa", "entra_privileged_user_has_mfa", "entra_security_defaults_enabled" @@ -512,7 +513,8 @@ "keyvault_non_rbac_secret_expiration_set", "keyvault_logging_enabled", "keyvault_private_endpoints", - "keyvault_access_only_through_private_endpoints" + "keyvault_access_only_through_private_endpoints", + "entra_app_registration_credential_not_expired" ] }, { diff --git a/prowler/compliance/azure/soc2_azure.json b/prowler/compliance/azure/soc2_azure.json index 80a6ed9929..c3b4db6e39 100644 --- a/prowler/compliance/azure/soc2_azure.json +++ b/prowler/compliance/azure/soc2_azure.json @@ -241,7 +241,8 @@ "app_function_not_publicly_accessible", "containerregistry_not_publicly_accessible", "network_public_ip_shodan", - "storage_blob_public_access_level_is_disabled" + "storage_blob_public_access_level_is_disabled", + "entra_app_registration_credential_not_expired" ] }, { @@ -318,7 +319,8 @@ "app_minimum_tls_version_12", "mysql_flexible_server_minimum_tls_version_12", "sqlserver_recommended_minimal_tls_version", - "storage_ensure_minimum_tls_version_12" + "storage_ensure_minimum_tls_version_12", + "network_subnet_nsg_associated" ], "ConfigRequirements": [ { diff --git a/prowler/compliance/csa_ccm_4.0.json b/prowler/compliance/csa_ccm_4.0.json index 6a0372dcb9..df541573af 100644 --- a/prowler/compliance/csa_ccm_4.0.json +++ b/prowler/compliance/csa_ccm_4.0.json @@ -1117,7 +1117,9 @@ "storage_blob_versioning_is_enabled", "storage_geo_redundant_enabled", "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" + "vm_scaleset_not_empty", + "cosmosdb_account_automatic_failover_enabled", + "cosmosdb_account_backup_policy_continuous" ], "gcp": [ "compute_instance_automatic_restart_enabled", @@ -1650,6 +1652,7 @@ "cloudfront_distributions_origin_traffic_encrypted", "cloudfront_distributions_custom_ssl_certificate", "transfer_server_in_transit_encryption_enabled", + "transfer_server_pqc_ssh_kex_enabled", "sagemaker_notebook_instance_encryption_enabled", "sagemaker_training_jobs_volume_and_output_encryption_enabled", "workspaces_volume_encryption_enabled", @@ -1843,6 +1846,7 @@ "acm_certificates_with_secure_key_algorithms", "elb_insecure_ssl_ciphers", "elbv2_insecure_ssl_ciphers", + "transfer_server_pqc_ssh_kex_enabled", "cloudfront_distributions_using_deprecated_ssl_protocols" ], "azure": [ diff --git a/prowler/compliance/dora.json b/prowler/compliance/dora_2022_2554.json similarity index 50% rename from prowler/compliance/dora.json rename to prowler/compliance/dora_2022_2554.json index 6dbf435bf6..e57557edee 100644 --- a/prowler/compliance/dora.json +++ b/prowler/compliance/dora_2022_2554.json @@ -114,6 +114,52 @@ "organizations_account_part_of_organizations", "iam_user_mfa_enabled_console_access", "iam_user_hardware_mfa_enabled" + ], + "azure": [ + "entra_global_admin_in_less_than_five_users", + "entra_privileged_user_has_mfa", + "entra_non_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa", + "entra_security_defaults_enabled", + "entra_conditional_access_policy_require_mfa_for_admin_portals", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_users_cannot_create_microsoft_365_groups", + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks" + ], + "gcp": [ + "compute_project_os_login_enabled", + "compute_project_os_login_2fa_enabled", + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_organization_essential_contacts_configured", + "iam_account_access_approval_enabled", + "iam_service_account_unused", + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "gke_cluster_no_default_service_account" + ], + "alibabacloud": [ + "ram_no_root_access_key", + "ram_user_mfa_enabled_console_access", + "ram_user_console_access_unused", + "ram_rotate_access_key_90_days", + "ram_password_policy_minimum_length", + "ram_password_policy_lowercase", + "ram_password_policy_uppercase", + "ram_password_policy_number", + "ram_password_policy_symbol", + "ram_password_policy_password_reuse_prevention", + "ram_password_policy_max_password_age", + "ram_password_policy_max_login_attempts", + "ram_policy_attached_only_to_group_or_roles", + "ram_policy_no_administrative_privileges" ] } }, @@ -136,6 +182,38 @@ "organizations_delegated_administrators", "guardduty_centrally_managed", "guardduty_delegated_admin_enabled_all_regions" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "policy_ensure_asc_enforcement_enabled" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled", + "logging_sink_created", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "iam_organization_essential_contacts_configured" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_all_assets_agent_installed", + "securitycenter_vulnerability_scan_enabled", + "actiontrail_multi_region_enabled" ] }, "config_requirements": [ @@ -193,6 +271,59 @@ "cloudfront_distributions_using_deprecated_ssl_protocols", "cloudfront_distributions_https_enabled", "rds_instance_transport_encrypted" + ], + "azure": [ + "storage_ensure_minimum_tls_version_12", + "storage_secure_transfer_required_is_enabled", + "storage_smb_channel_encryption_with_secure_algorithm", + "storage_smb_protocol_version_is_latest", + "app_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "app_ensure_using_http20", + "sqlserver_recommended_minimal_tls_version", + "mysql_flexible_server_minimum_tls_version_12", + "mysql_flexible_server_ssl_connection_enabled", + "postgresql_flexible_server_enforce_ssl_enabled", + "keyvault_key_rotation_enabled", + "storage_key_rotation_90_days", + "aks_network_policy_enabled" + ], + "gcp": [ + "cloudsql_instance_ssl_connections", + "compute_instance_encryption_with_csek_enabled", + "compute_instance_confidential_computing_enabled", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "dataproc_encrypted_with_cmks_disabled", + "kms_key_rotation_enabled", + "kms_key_rotation_max_90_days", + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "compute_network_not_legacy", + "compute_network_default_in_use", + "compute_instance_single_network_interface" + ], + "cloudflare": [ + "zone_universal_ssl_enabled", + "zone_ssl_strict", + "zone_min_tls_version_secure", + "zone_tls_1_3_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_automatic_https_rewrites_enabled", + "zone_development_mode_disabled", + "zone_record_caa_exists", + "zone_dnssec_enabled" + ], + "alibabacloud": [ + "oss_bucket_secure_transport_enabled", + "rds_instance_ssl_enabled", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom", + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "ecs_instance_no_legacy_network" ] }, "config_requirements": [ @@ -204,6 +335,23 @@ "RSA-1024", "P-192" ] + }, + { + "Check": "sqlserver_recommended_minimal_tls_version", + "ConfigKey": "recommended_minimal_tls_versions", + "Operator": "subset", + "Value": [ + "1.2", + "1.3" + ] + }, + { + "Check": "storage_smb_channel_encryption_with_secure_algorithm", + "ConfigKey": "recommended_smb_channel_encryption_algorithms", + "Operator": "subset", + "Value": [ + "AES-256-GCM" + ] } ] }, @@ -226,6 +374,29 @@ "ec2_elastic_ip_unassigned", "ec2_networkacl_unused", "secretsmanager_secret_unused" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "network_watcher_enabled", + "network_public_ip_shodan", + "vm_scaleset_not_empty" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_service_account_unused", + "iam_sa_user_managed_key_unused", + "apikeys_key_exists", + "compute_instance_suspended_without_persistent_disks", + "compute_public_address_shodan" + ], + "cloudflare": [ + "dns_record_no_wildcard", + "dns_record_no_internal_ip", + "dns_record_cname_target_valid" + ], + "alibabacloud": [ + "securitycenter_all_assets_agent_installed", + "ram_user_console_access_unused" ] }, "config_requirements": [ @@ -281,6 +452,127 @@ "ec2_instance_account_imdsv2_enabled", "efs_encryption_at_rest_enabled", "awslambda_function_not_publicly_accessible" + ], + "azure": [ + "storage_account_public_network_access_disabled", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "storage_account_key_access_disabled", + "storage_default_to_entra_authorization_enabled", + "containerregistry_not_publicly_accessible", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "cosmosdb_account_firewall_use_selected_networks", + "keyvault_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "keyvault_rbac_enabled", + "app_function_not_publicly_accessible", + "aisearch_service_not_publicly_accessible", + "aks_clusters_public_access_disabled", + "aks_clusters_created_with_private_nodes", + "sqlserver_unrestricted_inbound_access", + "postgresql_flexible_server_allow_access_services_disabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk", + "vm_ensure_using_managed_disks", + "vm_trusted_launch_enabled", + "vm_linux_enforce_ssh_authentication", + "vm_jit_access_enabled", + "sqlserver_tde_encryption_enabled", + "sqlserver_tde_encrypted_with_cmk", + "databricks_workspace_cmk_encryption_enabled", + "network_ssh_internet_access_restricted", + "network_rdp_internet_access_restricted", + "network_http_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_bastion_host_exists" + ], + "gcp": [ + "kms_key_not_publicly_accessible", + "bigquery_dataset_public_access", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "cloudstorage_bucket_public_access", + "cloudstorage_bucket_uniform_bucket_level_access", + "cloudstorage_uses_vpc_service_controls", + "cloudsql_instance_public_access", + "cloudsql_instance_public_ip", + "cloudsql_instance_private_ip_assignment", + "cloudsql_instance_ssl_connections", + "cloudsql_instance_cmek_encryption_enabled", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_instance_public_ip", + "compute_instance_shielded_vm_enabled", + "compute_instance_confidential_computing_enabled", + "compute_instance_serial_ports_in_use", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_instance_ip_forwarding_is_enabled", + "compute_instance_encryption_with_csek_enabled", + "compute_image_not_publicly_shared", + "dataproc_encrypted_with_cmks_disabled", + "kms_key_rotation_enabled", + "gke_cluster_no_default_service_account", + "cloudfunction_function_inside_vpc", + "apikeys_api_restrictions_configured", + "apikeys_api_restricted_with_gemini_api", + "cloudsql_instance_mysql_local_infile_flag", + "cloudsql_instance_mysql_skip_show_database_flag", + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag", + "cloudsql_instance_sqlserver_external_scripts_enabled_flag", + "cloudsql_instance_sqlserver_remote_access_flag", + "cloudsql_instance_sqlserver_trace_flag", + "cloudsql_instance_sqlserver_user_connections_flag", + "cloudsql_instance_sqlserver_user_options_flag" + ], + "cloudflare": [ + "zone_universal_ssl_enabled", + "zone_ssl_strict", + "zone_min_tls_version_secure", + "zone_tls_1_3_enabled", + "zone_hsts_enabled", + "zone_https_redirect_enabled", + "zone_automatic_https_rewrites_enabled", + "zone_record_caa_exists", + "zone_dnssec_enabled", + "zone_waf_enabled", + "zone_waf_owasp_ruleset_enabled", + "zone_firewall_blocking_rules_configured", + "zone_browser_integrity_check_enabled", + "zone_bot_fight_mode_enabled", + "zone_rate_limiting_enabled", + "zone_challenge_passage_configured", + "zone_ip_geolocation_enabled", + "dns_record_proxied", + "dns_record_no_internal_ip", + "dns_record_no_wildcard", + "dns_record_cname_target_valid", + "zone_record_spf_exists", + "zone_record_dkim_exists", + "zone_record_dmarc_exists" + ], + "alibabacloud": [ + "oss_bucket_not_publicly_accessible", + "oss_bucket_secure_transport_enabled", + "actiontrail_oss_bucket_not_publicly_accessible", + "ecs_attached_disk_encrypted", + "ecs_unattached_disk_encrypted", + "ecs_securitygroup_restrict_ssh_internet", + "ecs_securitygroup_restrict_rdp_internet", + "ecs_instance_endpoint_protection_installed", + "rds_instance_no_public_access_whitelist", + "rds_instance_ssl_enabled", + "rds_instance_tde_enabled", + "rds_instance_tde_key_custom", + "cs_kubernetes_network_policy_enabled", + "cs_kubernetes_eni_multiple_ip_enabled", + "cs_kubernetes_private_cluster_enabled", + "cs_kubernetes_rbac_enabled", + "cs_kubernetes_dashboard_disabled" ] } }, @@ -311,6 +603,44 @@ "inspector2_is_enabled", "inspector2_active_findings_exist", "ec2_elastic_ip_shodan" + ], + "azure": [ + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "defender_container_images_scan_enabled", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "compute_public_address_shodan", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled", + "cloudstorage_bucket_logging_enabled", + "compute_loadbalancer_logging_enabled", + "logging_sink_created" + ], + "cloudflare": [ + "zone_bot_fight_mode_enabled", + "zone_browser_integrity_check_enabled", + "zone_rate_limiting_enabled" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_notification_enabled_high_risk", + "securitycenter_all_assets_agent_installed", + "ecs_instance_endpoint_protection_installed", + "cs_kubernetes_cloudmonitor_enabled" ] }, "config_requirements": [ @@ -347,6 +677,32 @@ "backup_vaults_exist", "rds_instance_critical_event_subscription", "rds_cluster_critical_event_subscription" + ], + "azure": [ + "monitor_alert_service_health_exists", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_attack_path_notifications_properly_configured", + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period" + ], + "gcp": [ + "compute_instance_automatic_restart_enabled", + "compute_instance_group_autohealing_enabled", + "compute_instance_on_host_maintenance_migrate", + "cloudsql_instance_high_availability_enabled", + "cloudsql_instance_automated_backups", + "compute_instance_deletion_protection_enabled", + "compute_instance_group_load_balancer_attached", + "compute_instance_preemptible_vm_disabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk", + "cs_kubernetes_cluster_check_recent", + "cs_kubernetes_cluster_check_weekly" ] } }, @@ -388,6 +744,29 @@ "elbv2_is_in_multiple_az", "cloudfront_distributions_multiple_origin_failover_configured", "dynamodb_table_protected_by_backup_plan" + ], + "azure": [ + "vm_backup_enabled", + "vm_sufficient_daily_backup_retention_period", + "vm_ensure_using_managed_disks", + "storage_ensure_soft_delete_is_enabled", + "storage_ensure_file_shares_soft_delete_is_enabled", + "storage_blob_versioning_is_enabled", + "storage_geo_redundant_enabled", + "keyvault_recoverable" + ], + "gcp": [ + "cloudsql_instance_automated_backups", + "cloudsql_instance_high_availability_enabled", + "cloudstorage_bucket_versioning_enabled", + "cloudstorage_bucket_soft_delete_enabled", + "cloudstorage_bucket_lifecycle_management_enabled", + "cloudstorage_bucket_sufficient_retention_period", + "compute_instance_group_multiple_zones", + "compute_instance_disk_auto_delete_disabled", + "compute_instance_deletion_protection_enabled", + "compute_snapshot_not_outdated", + "compute_instance_suspended_without_persistent_disks" ] } }, @@ -407,6 +786,27 @@ "inspector2_active_findings_exist", "accessanalyzer_enabled_without_findings", "cloudtrail_insights_exist" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_assessments_vm_endpoint_protection_installed", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_system_updates_are_applied", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "iam_cloud_asset_inventory_enabled", + "logging_sink_created" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_all_assets_agent_installed", + "ecs_instance_latest_os_patches_applied" ] }, "config_requirements": [ @@ -437,6 +837,22 @@ "eventbridge_schema_registry_cross_account_access", "cloudwatch_alarm_actions_enabled", "cloudwatch_alarm_actions_alarm_state_configured" + ], + "azure": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "monitor_alert_service_health_exists" + ], + "gcp": [ + "iam_organization_essential_contacts_configured", + "logging_sink_created", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk" ] } }, @@ -473,6 +889,58 @@ "elbv2_logging_enabled", "cloudfront_distributions_logging_enabled", "s3_bucket_server_access_logging_enabled" + ], + "azure": [ + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_storage_account_with_activity_logs_is_private", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "keyvault_logging_enabled", + "sqlserver_auditing_enabled", + "sqlserver_auditing_retention_90_days", + "mysql_flexible_server_audit_log_enabled", + "mysql_flexible_server_audit_log_connection_activated", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3", + "app_http_logs_enabled", + "app_function_application_insights_enabled", + "appinsights_ensure_is_configured" + ], + "gcp": [ + "iam_audit_logs_enabled", + "cloudstorage_audit_logs_enabled", + "logging_sink_created", + "cloudstorage_bucket_logging_enabled", + "cloudstorage_bucket_log_retention_policy_lock", + "cloudstorage_bucket_sufficient_retention_period", + "compute_subnet_flow_logs_enabled", + "compute_network_dns_logging_enabled", + "compute_loadbalancer_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_messages_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag" + ], + "alibabacloud": [ + "actiontrail_multi_region_enabled", + "actiontrail_oss_bucket_not_publicly_accessible", + "oss_bucket_logging_enabled", + "sls_logstore_retention_period", + "vpc_flow_logs_enabled", + "cs_kubernetes_log_service_enabled", + "rds_instance_sql_audit_enabled", + "rds_instance_sql_audit_retention", + "rds_instance_postgresql_log_connections_enabled", + "rds_instance_postgresql_log_disconnections_enabled", + "rds_instance_postgresql_log_duration_enabled" ] } }, @@ -496,6 +964,26 @@ "cloudtrail_threat_detection_enumeration", "cloudtrail_threat_detection_llm_jacking", "cloudtrail_threat_detection_privilege_escalation" + ], + "azure": [ + "defender_ensure_notify_alerts_severity_is_high", + "defender_attack_path_notifications_properly_configured", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_mcas_is_enabled", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_public_address_shodan", + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled" + ], + "alibabacloud": [ + "securitycenter_notification_enabled_high_risk", + "securitycenter_vulnerability_scan_enabled" ] }, "config_requirements": [ @@ -540,6 +1028,48 @@ "cloudwatch_changes_to_network_route_tables_alarm_configured", "cloudwatch_changes_to_vpcs_alarm_configured", "sns_subscription_not_using_http_endpoints" + ], + "azure": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_delete_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_delete_nsg", + "monitor_alert_create_update_security_solution", + "monitor_alert_delete_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_sqlserver_fr", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_service_health_exists", + "defender_additional_email_configured_with_a_security_contact" + ], + "gcp": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_compute_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "iam_organization_essential_contacts_configured", + "logging_sink_created" + ], + "alibabacloud": [ + "sls_management_console_authentication_failures_alert_enabled", + "sls_management_console_signin_without_mfa_alert_enabled", + "sls_root_account_usage_alert_enabled", + "sls_unauthorized_api_calls_alert_enabled", + "sls_ram_role_changes_alert_enabled", + "sls_oss_bucket_policy_changes_alert_enabled", + "sls_oss_permission_changes_alert_enabled", + "sls_rds_instance_configuration_changes_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_customer_created_cmk_changes_alert_enabled" ] } }, @@ -560,6 +1090,30 @@ "ec2_instance_managed_by_ssm", "ec2_instance_with_outdated_ami", "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_ensure_system_updates_are_applied", + "defender_container_images_scan_enabled", + "defender_assessments_vm_endpoint_protection_installed", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "vm_ensure_using_approved_images", + "vm_desired_sku_size" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_instance_shielded_vm_enabled", + "compute_snapshot_not_outdated", + "compute_public_address_shodan" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "securitycenter_advanced_or_enterprise_edition", + "ecs_instance_latest_os_patches_applied", + "cs_kubernetes_cluster_check_recent", + "cs_kubernetes_cluster_check_weekly" ] }, "config_requirements": [ @@ -595,6 +1149,41 @@ "rds_instance_certificate_expiration", "iam_no_expired_server_certificates_stored", "ssm_managed_compliant_patching" + ], + "azure": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_vulnerability_assessment_enabled", + "sqlserver_va_periodic_recurring_scans_enabled", + "sqlserver_va_scan_reports_configured", + "sqlserver_va_emails_notifications_admins_enabled", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_secret_expiration_set", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_function_latest_runtime_version", + "storage_smb_protocol_version_is_latest" + ], + "gcp": [ + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_snapshot_not_outdated", + "compute_network_not_legacy", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "apikeys_key_rotated_in_90_days", + "iam_sa_user_managed_key_rotate_90_days" + ], + "cloudflare": [ + "zone_min_tls_version_secure" + ], + "alibabacloud": [ + "securitycenter_vulnerability_scan_enabled", + "ecs_instance_latest_os_patches_applied", + "ecs_instance_no_legacy_network" ] }, "config_requirements": [ @@ -640,6 +1229,40 @@ "vpc_endpoint_services_allowed_principals_trust_boundaries", "vpc_peering_routing_tables_with_least_privilege", "awslambda_function_using_cross_account_layers" + ], + "azure": [ + "entra_policy_guest_users_access_restrictions", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "storage_cross_tenant_replication_disabled", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "keyvault_access_only_through_private_endpoints", + "aks_clusters_created_with_private_nodes" + ], + "gcp": [ + "bigquery_dataset_public_access", + "cloudstorage_bucket_public_access", + "kms_key_not_publicly_accessible", + "cloudstorage_uses_vpc_service_controls", + "cloudfunction_function_inside_vpc", + "iam_sa_no_user_managed_keys", + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "apikeys_api_restrictions_configured", + "apikeys_api_restricted_with_gemini_api", + "compute_image_not_publicly_shared", + "iam_cloud_asset_inventory_enabled" + ], + "cloudflare": [ + "zone_record_caa_exists", + "dns_record_cname_target_valid" + ], + "alibabacloud": [ + "ram_policy_no_administrative_privileges", + "oss_bucket_not_publicly_accessible", + "actiontrail_oss_bucket_not_publicly_accessible" ] }, "config_requirements": [ @@ -678,6 +1301,32 @@ "iam_administrator_access_with_mfa", "iam_policy_attached_only_to_group_or_roles", "accessanalyzer_enabled" + ], + "azure": [ + "iam_subscription_roles_owner_custom_not_created", + "iam_role_user_access_admin_restricted", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "entra_global_admin_in_less_than_five_users", + "app_function_identity_without_admin_privileges", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps" + ], + "gcp": [ + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_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", + "gke_cluster_no_default_service_account", + "iam_account_access_approval_enabled", + "apikeys_api_restrictions_configured" + ], + "alibabacloud": [ + "ram_policy_no_administrative_privileges", + "ram_policy_attached_only_to_group_or_roles", + "ram_no_root_access_key" ] }, "config_requirements": [ @@ -709,6 +1358,28 @@ "cloudtrail_threat_detection_llm_jacking", "cloudtrail_threat_detection_privilege_escalation", "accessanalyzer_enabled_without_findings" + ], + "azure": [ + "defender_ensure_mcas_is_enabled", + "defender_ensure_wdatp_is_enabled", + "defender_ensure_defender_for_server_is_on", + "defender_attack_path_notifications_properly_configured", + "sqlserver_microsoft_defender_enabled", + "apim_threat_detection_llm_jacking" + ], + "gcp": [ + "iam_cloud_asset_inventory_enabled", + "iam_audit_logs_enabled", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled", + "compute_public_address_shodan", + "logging_sink_created" + ], + "alibabacloud": [ + "securitycenter_advanced_or_enterprise_edition", + "securitycenter_notification_enabled_high_risk", + "actiontrail_multi_region_enabled", + "sls_logstore_retention_period" ] }, "config_requirements": [ diff --git a/prowler/compliance/linode/__init__.py b/prowler/compliance/linode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/compliance/okta/__init__.py b/prowler/compliance/okta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json new file mode 100644 index 0000000000..bac3d9132e --- /dev/null +++ b/prowler/compliance/okta/okta_idaas_stig_v1r2_okta.json @@ -0,0 +1,638 @@ +{ + "Framework": "Okta-IDaaS-STIG", + "Name": "DISA Okta Identity as a Service (IDaaS) STIG V1R2", + "Version": "1R2", + "Provider": "Okta", + "Description": "Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS), Version 1 Release 2 (Benchmark Date: 05 Jan 2026).", + "Requirements": [ + { + "Id": "OKTA-APP-000020", + "Name": "Okta must log out a session after a 15-minute period of inactivity.", + "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead. Satisfies: SRG-APP-000003, SRG-APP-000190", + "Checks": [ + "signon_global_session_idle_timeout_15min" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273186r1098825_rule", + "StigID": "OKTA-APP-000020", + "CCI": [ + "CCI-000057", + "CCI-001133" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the edit icon next to the Priority 1 rule. 4. Verify the \"Maximum Okta global session idle time\" is set to 15 minutes. If \"Maximum Okta global session idle time\" is not set to 15 minutes, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session idle time\" to 15 minutes." + } + ] + }, + { + "Id": "OKTA-APP-000025", + "Name": "The Okta Admin Console must log out a session after a 15-minute period of inactivity.", + "Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead.", + "Checks": [ + "application_admin_console_session_idle_timeout_15min" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273187r1098828_rule", + "StigID": "OKTA-APP-000025", + "CCI": [ + "CCI-000057" + ], + "CheckText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", verify the \"Maximum app session idle time\" is set to 15 minutes. If the \"Maximum app session idle time\" is not set to 15 minutes, this is a finding.", + "FixText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", set the \"Maximum app session idle time\" to 15 minutes." + } + ] + }, + { + "Id": "OKTA-APP-000090", + "Name": "Okta must automatically disable accounts after a 35-day period of account inactivity.", + "Description": "Attackers that are able to exploit an inactive account can potentially obtain and maintain undetected access to an application. Owners of inactive accounts will not notice if unauthorized access to their user account has been obtained. Applications must track periods of user inactivity and disable accounts after 35 days of inactivity. Such a process greatly reduces the risk that accounts will be hijacked, leading to a data compromise. To address access requirements, many application developers choose to integrate their applications with enterprise-level authentication/access mechanisms that meet or exceed access control policy requirements. Such integration allows the application developer to off-load those access control functions and focus on core application features and functionality. This policy does not apply to emergency accounts or infrequently used accounts. Infrequently used accounts are local login administrator accounts used by system administrators when network or normal login/access is not available. Emergency accounts are administrator accounts created in response to crisis situations. Satisfies: SRG-APP-000025, SRG-APP-000163, SRG-APP-000700", + "Checks": [ + "user_inactivity_automation_35d_enabled" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273188r1098831_rule", + "StigID": "OKTA-APP-000090", + "CCI": [ + "CCI-000017", + "CCI-000795", + "CCI-003627" + ], + "CheckText": "If Okta Services rely on external directory services for user sourcing, this is not applicable, and the connected directory services must perform this function. Go to Workflows >> Automations and verify that an Automation has been created to disable accounts after 35 days of inactivity. If the Okta configuration does not automatically disable accounts after a 35-day period of account inactivity, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Workflow >> Automations and select \"Add Automation\". 2. Create a name for the Automation (e.g., \"User Inactivity\"). 3. Click \"Add Condition\" and select \"User Inactivity in Okta\". 4. In the duration field, enter 35 days and click \"Save\". 5 Click the edit button next to \"Select Schedule\". 6. Configure the \"Schedule\" field for \"Run Daily\" and set the \"Time\" field to an organizationally defined time to run this automation. Click \"Save\". 7. Click the edit button next to \"Select group membership\". 8. In the \"Applies to\" field, select the group \"Everyone\" by typing it into the field. Click \"Save\". 9. Click \"Add Action\" and select \"Change User lifecycle state in Okta\". 10. In the \"Change user state to\" field, select \"Suspended\" and click \"Save\". 11. Click the \"Inactive\" button near the top of the section screen and select \"Activate\"." + } + ] + }, + { + "Id": "OKTA-APP-000170", + "Name": "Okta must enforce the limit of three consecutive invalid login attempts by a user during a 15-minute time period.", + "Description": "By limiting the number of failed login attempts, the risk of unauthorized system access via user password guessing, otherwise known as brute forcing, is reduced. Limits are imposed by locking the account. Satisfies: SRG-APP-000065, SRG-APP-000345", + "Checks": [ + "authenticator_password_lockout_threshold_3" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273189r1098834_rule", + "StigID": "OKTA-APP-000170", + "CCI": [ + "CCI-000044", + "CCI-002238" + ], + "CheckText": "If Okta Services rely on external directory services for user sourcing, this check is not applicable, and the connected directory services must perform this function. From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, verify the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\". If Okta Services are not configured to automatically lock user accounts after three consecutive invalid login attempts, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, ensure the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\"." + } + ] + }, + { + "Id": "OKTA-APP-000180", + "Name": "The Okta Dashboard application must be configured to allow authentication only via non-phishable authenticators.", + "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.", + "Checks": [ + "application_dashboard_phishing_resistant_authentication" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273190r1099763_rule", + "StigID": "OKTA-APP-000180", + "CCI": [ + "CCI-000044" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked." + } + ] + }, + { + "Id": "OKTA-APP-000190", + "Name": "The Okta Admin Console application must be configured to allow authentication only via non-phishable authenticators.", + "Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.", + "Checks": [ + "application_admin_console_phishing_resistant_authentication" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273191r1099764_rule", + "StigID": "OKTA-APP-000190", + "CCI": [ + "CCI-000044" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked." + } + ] + }, + { + "Id": "OKTA-APP-000200", + "Name": "Okta must display the Standard Mandatory DOD Notice and Consent Banner before granting access to the application.", + "Description": "Display of the DOD-approved use notification before granting access to the application ensures that privacy and security notification verbiage used is consistent with applicable federal laws, Executive Orders, directives, policies, regulations, standards, and guidance. System use notifications are required only for access via login interfaces with human users and are not required when such human interfaces do not exist. The banner must be formatted in accordance with DTM-08-060. Use the following verbiage for applications that can accommodate banners of 1300 characters: \"You are accessing a U.S. Government (USG) Information System (IS) that is provided for USG-authorized use only. By using this IS (which includes any device attached to this IS), you consent to the following conditions: -The USG routinely intercepts and monitors communications on this IS for purposes including, but not limited to, penetration testing, COMSEC monitoring, network operations and defense, personnel misconduct (PM), law enforcement (LE), and counterintelligence (CI) investigations. -At any time, the USG may inspect and seize data stored on this IS. -Communications using, or data stored on, this IS are not private, are subject to routine monitoring, interception, and search, and may be disclosed or used for any USG-authorized purpose. -This IS includes security measures (e.g., authentication and access controls) to protect USG interests--not for your personal benefit or privacy. -Notwithstanding the above, using this IS does not constitute consent to PM, LE or CI investigative searching or monitoring of the content of privileged communications, or work product, related to personal representation or services by attorneys, psychotherapists, or clergy, and their assistants. Such communications and work product are private and confidential. See User Agreement for details.\" Use the following verbiage for operating systems that have severe limitations on the number of characters that can be displayed in the banner: \"I've read & consent to terms in IS user agreem't.\" Satisfies: SRG-APP-000068, SRG-APP-000069, SRG-APP-000070", + "Checks": [ + "signon_dod_warning_banner_configured" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273192r1098843_rule", + "StigID": "OKTA-APP-000200", + "CCI": [ + "CCI-000048", + "CCI-000050", + "CCI-001384", + "CCI-001385", + "CCI-001386", + "CCI-001387", + "CCI-001388" + ], + "CheckText": "Attempt to log in to the Okta tenant and verify the DOD-approved warning banner is in place. If the required warning banner is not present and complete, this is a finding.", + "FixText": "Follow the supplemental instructions in the \"Okta DOD Warning Banner Configuration Guide\" provided with this STIG package." + } + ] + }, + { + "Id": "OKTA-APP-000560", + "Name": "The Okta Admin Console application must be configured to use multifactor authentication.", + "Description": "Without the use of multifactor authentication, the ease of access to privileged functions is greatly increased. Multifactor authentication requires using two or more factors to achieve authentication. Factors include: (i) something a user knows (e.g., password/PIN); (ii) something a user has (e.g., cryptographic identification device, token); or (iii) something a user is (e.g., biometric). A privileged account is defined as an information system account with authorizations of a privileged user. Network access is defined as access to an information system by a user (or a process acting on behalf of a user) communicating through a network (e.g., local area network, wide area network, or the internet). Satisfies: SRG-APP-000149, SRG-APP-000154", + "Checks": [ + "application_admin_console_mfa_required" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273193r1098846_rule", + "StigID": "OKTA-APP-000560", + "CCI": [ + "CCI-000765", + "CCI-004046" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"." + } + ] + }, + { + "Id": "OKTA-APP-000570", + "Name": "The Okta Dashboard application must be configured to use multifactor authentication.", + "Description": "To ensure accountability and prevent unauthenticated access, nonprivileged users must use multifactor authentication to prevent potential misuse and compromise of the system. Multifactor authentication uses two or more factors to achieve authentication. Factors include: (i) Something you know (e.g., password/PIN); (ii) Something you have (e.g., cryptographic identification device, token); or (iii) Something you are (e.g., biometric). A nonprivileged account is any information system account with authorizations of a nonprivileged user. Network access is any access to an application by a user (or process acting on behalf of a user) where the access is obtained through a network connection. Applications integrating with the DOD Active Directory and using the DOD CAC are examples of compliant multifactor authentication solutions. Satisfies: SRG-APP-000150, SRG-APP-000155", + "Checks": [ + "application_dashboard_mfa_required" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273194r1098849_rule", + "StigID": "OKTA-APP-000570", + "CCI": [ + "CCI-000766", + "CCI-004046" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"." + } + ] + }, + { + "Id": "OKTA-APP-000650", + "Name": "Okta must enforce a minimum 15-character password length.", + "Description": "Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password length is one factor of several that helps to determine strength and how long it takes to crack a password. The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised. Use of more characters in a password helps to exponentially increase the time and/or resources required to compromise the password.", + "Checks": [ + "authenticator_password_minimum_length_15" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273195r1098852_rule", + "StigID": "OKTA-APP-000650", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify the \"Minimum Length\" field is set to at least \"15\" characters. If any policy is not set to at least \"15\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set the \"Minimum Length\" field to at least \"15\" characters." + } + ] + }, + { + "Id": "OKTA-APP-000670", + "Name": "Okta must enforce password complexity by requiring that at least one uppercase character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password is, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_uppercase" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273196r1098855_rule", + "StigID": "OKTA-APP-000670", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Upper case letter\" is checked. For each policy, if \"Upper case letter\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Upper case letter\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000680", + "Name": "Okta must enforce password complexity by requiring that at least one lowercase character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_lowercase" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273197r1098858_rule", + "StigID": "OKTA-APP-000680", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Lower case letter\" is checked. For each policy, if \"Lower case letter\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Lower case letter\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000690", + "Name": "Okta must enforce password complexity by requiring that at least one numeric character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.", + "Checks": [ + "authenticator_password_complexity_number" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273198r1098861_rule", + "StigID": "OKTA-APP-000690", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Number (0-9)\" is checked. For each policy, if \"Number (0-9)\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Number (0-9)\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000700", + "Name": "Okta must enforce password complexity by requiring that at least one special character be used.", + "Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor in determining how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised. Special characters are not alphanumeric. Examples include: ~ ! @ # $ % ^ *.", + "Checks": [ + "authenticator_password_complexity_symbol" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273199r1098864_rule", + "StigID": "OKTA-APP-000700", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Symbol (e.g., !@#$%^&*)\" is checked. For each policy, if \"Symbol (e.g., !@#$%^&*)\" is not checked, this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Symbol (e.g., !@#$%^&*)\" to checked." + } + ] + }, + { + "Id": "OKTA-APP-000740", + "Name": "Okta must enforce 24 hours/one day as the minimum password lifetime.", + "Description": "Enforcing a minimum password lifetime helps prevent repeated password changes to defeat the password reuse or history enforcement requirement. Restricting this setting limits the user's ability to change their password. Passwords must be changed at specific policy-based intervals; however, if the application allows the user to immediately and continually change their password, it could be changed repeatedly in a short period of time to defeat the organization's policy regarding password reuse. Satisfies: SRG-APP-000173, SRG-APP-000870", + "Checks": [ + "authenticator_password_minimum_age_24h" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273200r1098867_rule", + "StigID": "OKTA-APP-000740", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Minimum password age is XX hours\" is set to at least \"24\". For each policy, if \"Minimum password age is XX hours\" is not set to at least \"24\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Minimum password age is XX hours\" to at least \"24\"." + } + ] + }, + { + "Id": "OKTA-APP-000745", + "Name": "Okta must enforce a 60-day maximum password lifetime restriction.", + "Description": "Any password, no matter how complex, can eventually be cracked. Therefore, passwords must be changed at specific intervals. One method of minimizing this risk is to use complex passwords and periodically change them. If the application does not limit the lifetime of passwords and force users to change their passwords, there is the risk that the system and/or application passwords could be compromised. This requirement does not include emergency administration accounts, which are meant for access to the application in case of failure. These accounts are not required to have maximum password lifetime restrictions.", + "Checks": [ + "authenticator_password_maximum_age_60d" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273201r1098870_rule", + "StigID": "OKTA-APP-000745", + "CCI": [ + "CCI-004066" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Password expires after XX days\" is set to \"60\". For each policy, if \"Password expires after XX days\" is not set to \"60\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Password expires after XX days\" to \"60\"." + } + ] + }, + { + "Id": "OKTA-APP-001430", + "Name": "Okta must off-load audit records onto a central log server.", + "Description": "Information stored in one location is vulnerable to accidental or incidental deletion or alteration. Off-loading is a common process in information systems with limited audit storage capacity. Satisfies: SRG-APP-000358, SRG-APP-000080, SRG-APP-000125", + "Checks": [ + "systemlog_streaming_enabled" + ], + "Attributes": [ + { + "Section": "CAT I (High)", + "Severity": "high", + "RuleID": "SV-273202r1099766_rule", + "StigID": "OKTA-APP-001430", + "CCI": [ + "CCI-001851", + "CCI-000166", + "CCI-001348" + ], + "CheckText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Verify that a Log Stream connection is configured and active. Alternately, interview the information system security manager (ISSM) and verify that an external Security Information and Event Management (SIEM) system is pulling Okta logs via an Application Programming Interface (API). If either of these is not configured, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Select either \"AWS EventBridge\" or \"Splunk Cloud\" and click \"Next\". 3. Complete the necessary fields and click \"Save\". If Log Streaming is not an option because the SIEM required is not an option, customers can use the Okta Log API to export system logs in real time." + } + ] + }, + { + "Id": "OKTA-APP-001665", + "Name": "Okta must be configured to limit the global session lifetime to 18 hours.", + "Description": "Without reauthentication, users may access resources or perform tasks for which they do not have authorization. When applications provide the capability to change security roles or escalate the functional capability of the application, it is critical the user reauthenticate. In addition to the reauthentication requirements associated with session locks, organizations may require reauthentication of individuals and/or devices in other situations, including (but not limited to) the following circumstances. (i) When authenticators change; (ii) When roles change; (iii) When security categories of information systems change; (iv) When the execution of privileged functions occurs; (v) After a fixed period of time; or (vi) Periodically. Within the DOD, the minimum circumstances requiring reauthentication are privilege escalation and role changes.", + "Checks": [ + "signon_global_session_lifetime_18h" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273203r1099958_rule", + "StigID": "OKTA-APP-001665", + "CCI": [ + "CCI-002038" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Maximum Okta global session lifetime\" is set to 18 hours. If the above is not set, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session lifetime\" to 18 hours." + } + ] + }, + { + "Id": "OKTA-APP-001670", + "Name": "Okta must be configured to accept Personal Identity Verification (PIV) credentials.", + "Description": "The use of PIV credentials facilitates standardization and reduces the risk of unauthorized access. DOD has mandated the use of the common access card (CAC) to support identity management and personal authentication for systems covered under HSPD 12, as well as a primary component of layered protection for national security systems. Satisfies: SRG-APP-000391, SRG-APP-000402, SRG-APP-000403", + "Checks": [ + "authenticator_smart_card_active" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273204r1098879_rule", + "StigID": "OKTA-APP-001670", + "CCI": [ + "CCI-001953", + "CCI-002009", + "CCI-002010" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Verify that \"Smart Card Authenticator\" is listed and has \"Status\" listed as \"Active\". If \"Smart Card Authenticator\" is not listed or is not listed as \"Active\", this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. In the \"Setup\" tab, click \"Add authenticator\". 3. Select the configured Smart Card Identity Provider and finish configuration." + } + ] + }, + { + "Id": "OKTA-APP-001700", + "Name": "The Okta Verify application must be configured to connect only to FIPS-compliant devices.", + "Description": "Without device-to-device authentication, communications with malicious devices may be established. Bidirectional authentication provides stronger safeguards to validate the identity of other devices for connections that are of greater risk. Currently, DOD requires the use of AES for bidirectional authentication because it is the only FIPS-validated AES cipher block algorithm. For distributed architectures (e.g., service-oriented architectures), the decisions regarding the validation of authentication claims may be made by services separate from the services acting on those decisions. In such situations, it is necessary to provide authentication decisions (as opposed to the actual authenticators) to the services that need to act on those decisions. A local connection is any connection with a device communicating without the use of a network. A network connection is any connection with a device that communicates through a network (e.g., local area or wide area network; the internet). A remote connection is any connection with a device communicating through an external network (e.g., the internet). Because of the challenges of applying this requirement on a large scale, organizations are encouraged to apply the requirement only to those limited number (and type) of devices that truly need to support this capability.", + "Checks": [ + "authenticator_okta_verify_fips_compliant" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273205r1098882_rule", + "StigID": "OKTA-APP-001700", + "CCI": [ + "CCI-001967" + ], + "CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. Review the \"FIPS Compliance\" field. If FIPS-compliant authentication is not enabled, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. In the \"FIPS Compliance\" field, choose whether users enrolling in Okta Verify can use FIPS-compliant devices only or any device. 4. Click \"Save\" after making any changes." + } + ] + }, + { + "Id": "OKTA-APP-001710", + "Name": "Okta must be configured to disable persistent global session cookies.", + "Description": "If cached authentication information is out of date, the validity of the authentication information may be questionable. Satisfies: SRG-APP-000400, SRG-APP-000157", + "Checks": [ + "signon_global_session_cookies_not_persistent" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273206r1098885_rule", + "StigID": "OKTA-APP-001710", + "CCI": [ + "CCI-002007", + "CCI-001942" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Okta global session cookies persist across browser sessions\" is set to \"Disabled\". If the above it not set, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the \"Rules\" table, make these updates: - Click \"Add rule\". - Set \"Okta global session cookies persist across browser sessions\" to Disable." + } + ] + }, + { + "Id": "OKTA-APP-001920", + "Name": "Okta must be configured to use only DOD-approved certificate authorities.", + "Description": "Untrusted Certificate Authorities (CA) can issue certificates, but they may be issued by organizations or individuals that seek to compromise DOD systems or by organizations with insufficient security controls. If the CA used for verifying the certificate is not DOD approved, trust of this CA has not been established. The DOD will accept only PKI certificates obtained from a DOD-approved internal or external CA. Reliance on CAs for the establishment of secure sessions includes, for example, the use of Transport Layer Security (TLS) certificates. This requirement focuses on communications protection for the application session rather than for the network packet. This requirement applies to applications that use communications sessions. This includes, but is not limited to, web-based applications and Service-Oriented Architectures (SOA). Satisfies: SRG-APP-000427, SRG-APP-000910", + "Checks": [ + "idp_smart_card_dod_approved_ca" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273207r1098888_rule", + "StigID": "OKTA-APP-001920", + "CCI": [ + "CCI-002470", + "CCI-004909" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Identity Providers (IdPs). 2. Review the list of IdPs with \"Type\" as \"Smart Card\". If the IdP is not listed as \"Active\", this is a finding. 3. Select Actions >> Configure. 4. Under \"Certificate chain\", verify the certificate is from a DOD-approved CA. If the certificate is not from a DOD-approved CA, this is a finding.", + "FixText": "From the Admin Console: 1. Go to Security >> Identity Providers. 2. Click \"Add identity provider.\" 3. Click \"Smart Card IdP\". Click \"Next\". 4. Enter the name of the identity provider. 5. Build a certificate chain: - Click \"Browse\" to open a file explorer. Select the certificate file to add and click \"Open\". - To add another certificate, click \"Add Another\" and repeat step 1. - Click \"Build certificate chain\". On success, the chain and its certificates are shown. If the build failed, correct any issues and try again. - Click \"Reset certificate chain\" if replacing the current chain with a new one. 6. In \"IdP username\", select the \"idpuser.subjectAltNameUpn\" attribute. This is the attribute that stores the Electronic Data Interchange Personnel Identifier (EDIPI) on the CAC. 7. In the \"Match Against\" field, select the Okta Profile Attribute in which the EDIPI is to be stored." + } + ] + }, + { + "Id": "OKTA-APP-002980", + "Name": "Okta must validate passwords against a list of commonly used, expected, or compromised passwords.", + "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.", + "Checks": [ + "authenticator_password_common_password_check" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273208r1099769_rule", + "StigID": "OKTA-APP-002980", + "CCI": [ + "CCI-004058" + ], + "CheckText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, verify the \"Common Password Check\" box is checked. If \"Common Password Check\" is not selected, this is a finding.", + "FixText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, check the \"Common Password Check\" box." + } + ] + }, + { + "Id": "OKTA-APP-003010", + "Name": "Okta must prohibit password reuse for a minimum of five generations.", + "Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.", + "Checks": [ + "authenticator_password_history_5" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-273209r1098894_rule", + "StigID": "OKTA-APP-003010", + "CCI": [ + "CCI-004061" + ], + "CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password row\" and select \"Edit\". 3. For each listed policy, verify \"Enforce password history for last XX passwords\" is set to \"5\". If any policy is not set to at least \"5\", this is a finding.", + "FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Enforce password history for last XX passwords\" to \"5\"." + } + ] + }, + { + "Id": "OKTA-APP-003240", + "Name": "Okta API tokens must be configured with Network Zones to restrict authorization from known networks.", + "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. API tokens have the potential to be replicated or stolen (just like a password). Because of this, it is important to only allow API tokens to authenticate from known IP ranges as this limits an adversary's ability to use a token to gain access.", + "Checks": [ + "apitoken_restricted_to_network_zone" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279689r1155066_rule", + "StigID": "OKTA-APP-003240", + "CCI": [ + "CCI-005165", + "CCI-000366" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, verify the \"Token can be used from\" setting is mapped to a known network zone for the application calling the API. If a network zone for each API access token is not defined, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, click \"Edit\". 5. Set the \"Token can be used from\" setting to the known network zone for the application calling the API. 6. Click \"Save\"." + } + ] + }, + { + "Id": "OKTA-APP-003241", + "Name": "Okta API tokens must be created under new dedicated user accounts.", + "Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. When API tokens are created, they inherit the permissions of the user that created them. Therefore, API tokens should only be created from dedicated accounts and permissions must be constrained to least privilege for that dedicated user account and token. No API tokens should be created using a Super Admin account.", + "Checks": [ + "apitoken_not_super_admin" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279690r1155069_rule", + "StigID": "OKTA-APP-003241", + "CCI": [ + "CCI-005165", + "CCI-000366" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, verify that the Role listed is not \"Super Admin\", and that the account has been specifically created for that token. 4. Click the account name to be token to the user profile for that user. 5. Verify the user only has an administrator role (standard or customer) applied that is correctly scoped as required and documented in the Okta Access Control policy. If the token is using a Super Administrator account, or one that is not properly scoped per the Access Control policy, this is a finding. Note: If a Super Admin token is required for system operation, then this permanent finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed that has \"Super Admin\" or an improperly scoped Admin account, delete the token and create a new one with the appropriately scoped permissions. 4. Verify the application performing the API calls with the new token has been updated." + } + ] + }, + { + "Id": "OKTA-APP-003242", + "Name": "The Okta Global Session policy must be configured to allow or deny IP based access in accordance with the Access Control policy for Okta.", + "Description": "To mitigate the risk of unauthorized access to sensitive information by entities that have been issued certificates by DOD-approved PKIs, all DOD systems (e.g., networks, web servers, and web portals) must be properly configured to incorporate access control methods that do not rely solely on the possession of a certificate for access. Successful authentication must not automatically give an entity access to an asset or security boundary. Authorization procedures and controls must be implemented to ensure each authenticated entity also has a validated and current authorization. Authorization is the process of determining whether an entity, once authenticated, is permitted to access a specific asset. Information systems use access control policies and enforcement mechanisms to implement this requirement. Access Control policies include identity-based policies, role-based policies, and attribute-based policies. Access enforcement mechanisms include access control lists, access control matrices, and cryptography. These policies and mechanisms must be employed by the application to control access between users (or processes acting on behalf of users) and objects (e.g., devices, files, records, processes, programs, and domains) in the information system. The Okta Global Session Policy is applied at the organization level and before any application-specific authentication policies are processed. The Okta authorization package should contain an access control policy that defines IP ranges from which to either allow or deny access. This list (either as an explicit allow or explicit deny) can be implemented in the Global Session Policy.", + "Checks": [ + "signon_global_session_policy_network_zone_enforced" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279691r1155072_rule", + "StigID": "OKTA-APP-003242", + "CCI": [ + "CCI-000213" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the \"Policy Settings\" section, verify the \"IF User's IP is\" setting is correctly set to either allow or deny based on the organization defined policy. If the Okta Global Session Policy is not configured to restrict access to specific IP ranges, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the Policy Settings section, configure the \"IF User's IP is\" setting to correctly set the appropriate network to either allow or deny based on the Access Control Policy." + } + ] + }, + { + "Id": "OKTA-APP-003243", + "Name": "Okta must be configured with Network Zones defined to block anonymized proxies according to organizationally defined policy.", + "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Working with the organizational CSSP, the ISSM should obtain a list of known anonymizer proxies that exist on the commercial internet. If this is not available from the CSSP, then the Okta-provided \"Enhanced dynamic zone blocklist\" should be activated.", + "Checks": [ + "network_zone_block_anonymized_proxies" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279692r1155075_rule", + "StigID": "OKTA-APP-003243", + "CCI": [ + "CCI-001414" + ], + "CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks' item. 2. If the CSSP has provided a list of anonymizers to block, verify the \"IP Block list\" is configured with them. a. Click the pencil icon next to IP Block list. b. Verify the \"Gateway IPs\" section contains all of the IP ranges in the provided list. 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Verify the \"Enhanced dynamic zone blocklist\" is set to \"Active\". If Network Zones are not configured to block anonymous proxies, this is a finding.", + "FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks\" item. 2. If the CSSP has provided a list of anonymizers to block, add the IP ranges to the \"IP Block list\". a. Click the pencil icon next to IP Block list. b. Add the IP ranges to the \"Gateway IPs\" section and click \"Save\". 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Set the \"Enhanced dynamic zone blocklist\" to \"Active\"." + } + ] + }, + { + "Id": "OKTA-APP-003244", + "Name": "For each application integrated with Okta, network zones must be defined in its authentication policy.", + "Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Each application in Okta should have a well defined access control policy that takes into account the end user network. This should be documented in the Access Control policy for each application. As an example, access to an application may be restricted to a specific location by policy. In this case, a network defining that specific location should be created.", + "Checks": [ + "application_authentication_policy_network_zone_enforced" + ], + "Attributes": [ + { + "Section": "CAT II (Medium)", + "Severity": "medium", + "RuleID": "SV-279693r1155078_rule", + "StigID": "OKTA-APP-003244", + "CCI": [ + "CCI-001414" + ], + "CheckText": "For each application integrated into Okta: 1. From the Admin console, open the \"Security\" menu, and then select \"Networks\". 2. Verify the list of networks includes all necessary allow or block lists. If any application is not configured with network zones, this is a finding.", + "FixText": "For each application, starting at the admin console: 1. Open the \"Applications\" group from the Menu, and then click the \"Applications\" menu item. 2. Click the application name. 3. Click the \"Sign On\" tab. 4. Scroll to the \"User Authentication\" section, and then click \"Edit\". 5. Select the appropriate Authentication policy from the pull down, and then click \"Save\". 6. Click \"View Policy Details\". 7. For each nondefault rule: a. Select \"Edit\" from the Actions menu. b. In the \"IF\" section, verify the \"User is\" setting has the appropriate allow or deny range has been selected based on the Access Control policy for the application. c. Scroll down to the bottom and click \"Save\". 8. For the Catch-All rule: a. Select \"Edit\" from the Actions menu. b. Scroll down to the \"Then\" section. c. For the \"Access is\" setting, select \"Denied\", and then click \"Save\"." + } + ] + } + ] +} diff --git a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json index ceca68d124..8140dca6cc 100644 --- a/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json +++ b/prowler/compliance/oraclecloud/cis_3.1_oraclecloud.json @@ -302,7 +302,9 @@ { "Id": "1.15", "Description": "Ensure storage service-level admins cannot delete resources they manage", - "Checks": [], + "Checks": [ + "identity_storage_service_level_admins_scoped" + ], "Attributes": [ { "Section": "1. Identity and Access Management", diff --git a/prowler/config/config.py b/prowler/config/config.py index 98f5290a80..8be31961bd 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -49,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.30.0" +prowler_version = "5.31.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" @@ -80,6 +80,7 @@ class Provider(str, Enum): VERCEL = "vercel" OKTA = "okta" STACKIT = "stackit" + LINODE = "linode" # Compliance diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 28f07c2051..59f69ec821 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -380,6 +380,39 @@ aws: # Minimum number of Availability Zones that an ELBv2 must be in elbv2_min_azs: 2 + # AWS Post-Quantum TLS Configuration + # aws.acmpca_certificate_authority_pqc_key_algorithm + # Allowed post-quantum key algorithms for AWS Private CA certificate authorities + acmpca_pqc_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + # aws.cloudfront_distributions_pqc_tls_enabled + # Allowed CloudFront MinimumProtocolVersion values that enable post-quantum hybrid key exchange + cloudfront_pqc_min_protocol_versions: + - "TLSv1.3_2025" + # aws.apigateway_domain_name_pqc_tls_enabled + # Allowed post-quantum TLS security policies for API Gateway custom domain names + apigateway_pqc_tls_allowed_policies: + - "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09" + - "SecurityPolicy_TLS13_1_2_PQ_2025_09" + + # aws.rolesanywhere_trust_anchor_pqc_pki + # Allowed post-quantum key algorithms for AWS Private CAs backing IAM Roles Anywhere trust anchors + rolesanywhere_pqc_pca_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" + + # AWS Post-Quantum SSH Key Exchange Configuration + # aws.transfer_server_pqc_ssh_kex_enabled + # Allowed AWS Transfer Family security policies with post-quantum SSH key exchange + transfer_pqc_ssh_allowed_policies: + - "TransferSecurityPolicy-2025-03" + - "TransferSecurityPolicy-FIPS-2025-03" + - "TransferSecurityPolicy-AS2Restricted-2025-07" + # AWS Elasticache Configuration # aws.elasticache_redis_cluster_backup_enabled # Minimum number of days that a Redis cluster must have backups retention period diff --git a/prowler/config/linode_mutelist_example.yaml b/prowler/config/linode_mutelist_example.yaml new file mode 100644 index 0000000000..1b08ab6fda --- /dev/null +++ b/prowler/config/linode_mutelist_example.yaml @@ -0,0 +1,18 @@ +### Account, Check and/or Region can be * to apply for all the cases. +### Account == +### Region == * (Linode is non-regional) +### 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: + "example-account-uuid": + Checks: + "administration_user_2fa_enabled": + Regions: + - "*" + Resources: + - "example-user@example.com" + - "another-user@example.com" diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index 72031805e2..8115983bc6 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -3,12 +3,13 @@ from colorama import Fore, Style from prowler.config.config import banner_color, orange_color, prowler_version, timestamp -def print_banner(legend: bool = False): +def print_banner(legend: bool = False, provider: str = None): """ Prints the banner with optional legend for color codes. Parameters: - legend (bool): Flag to indicate whether to print the color legend or not. Default is False. + - provider (str): The provider being scanned, used to tailor the Prowler Cloud banner. Returns: - None @@ -20,20 +21,50 @@ def print_banner(legend: bool = False): | .__/|_| \___/ \_/\_/ |_|\___|_|v{prowler_version} |_|{Fore.BLUE} Get the most at https://cloud.prowler.com {Style.RESET_ALL} -{Fore.GREEN}New! Send findings from Prowler CLI to Prowler Cloud{Style.RESET_ALL} -{Fore.GREEN}More details here: goto.prowler.com/import-findings{Style.RESET_ALL} - {Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL} """ print(banner) + print_prowler_cloud_banner(provider) + if legend: - print( - f""" + print(f""" {Style.BRIGHT}Color code for results:{Style.RESET_ALL} - {Fore.YELLOW}MANUAL (Manual check){Style.RESET_ALL} - {Fore.GREEN}PASS (Recommended value){Style.RESET_ALL} - {orange_color}MUTED (Muted by muted list){Style.RESET_ALL} - {Fore.RED}FAIL (Fix required){Style.RESET_ALL} - """ - ) + """) + + +def print_prowler_cloud_banner(provider: str = None): + """ + Prints a promotional banner highlighting what Prowler Cloud adds on top of + the open-source CLI. + + Shown at the start and end of a scan to let users know about the managed + platform capabilities they are missing (attack paths, AI, organizations, + continuous scanning, integrations and live compliance dashboards). + + Parameters: + - provider (str): The provider that was scanned, used to tailor the message. + + Returns: + - None + """ + check = f"{Fore.GREEN}✓{Style.RESET_ALL}" + bar = f"{banner_color}│{Style.RESET_ALL}" + print(f""" +{bar} {Style.BRIGHT}You're getting a snapshot 📸. Prowler Cloud gives you the full picture:{Style.RESET_ALL} +{bar} +{bar} {check} {Style.BRIGHT}Continuous Security Monitoring{Style.RESET_ALL} - scheduled scans with history, trends and alerts. +{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation. +{bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening. +{bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date. +{bar} {check} {Style.BRIGHT}Remediation{Style.RESET_ALL} - complete guided remediation including Autonomous remediation with Lighthouse AI. +{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels. +{bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds. +{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC. +{bar} +{bar} {Fore.BLUE}Start free at 👉 cloud.prowler.com{Style.RESET_ALL} +""") diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index 300520f589..c0c6a02e5d 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -797,6 +797,10 @@ def execute( is_finding_muted_args["org_domain"] = ( global_provider.identity.org_domain ) + elif global_provider.type == "linode": + is_finding_muted_args["account_id"] = ( + global_provider.identity.account_id + ) elif not is_builtin_provider(global_provider.type): # External/custom provider — delegate identity args is_finding_muted_args = global_provider.get_mutelist_finding_args() diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 9ee26c9df4..090283c1d7 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -283,6 +283,26 @@ class CSA_CCM_Requirement_Attribute(BaseModel): ScopeApplicability: list[dict] +class STIG_Requirement_Attribute_Severity(str, Enum): + """DISA STIG Requirement Attribute Severity (maps to CAT I/II/III)""" + + high = "high" + medium = "medium" + low = "low" + + +class STIG_Requirement_Attribute(BaseModel): + """DISA STIG Requirement Attribute""" + + Section: str + Severity: STIG_Requirement_Attribute_Severity + RuleID: str + StigID: str + CCI: Optional[list[str]] = None + CheckText: Optional[str] = None + FixText: Optional[str] = None + + # Base Compliance Model class Compliance_Requirement_ConfigConstraint(BaseModel): """A constraint a requirement places on a configurable check's config. @@ -331,6 +351,7 @@ class Compliance_Requirement(BaseModel): CCC_Requirement_Attribute, C5Germany_Requirement_Attribute, CSA_CCM_Requirement_Attribute, + STIG_Requirement_Attribute, # Generic_Compliance_Requirement_Attribute must be the last one since it is the fallback for generic compliance framework Generic_Compliance_Requirement_Attribute, ] diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index f9155c68f7..aa16d7969b 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -1106,6 +1106,37 @@ class CheckReportCloudflare(Check_Report): return "global" +@dataclass +class CheckReportLinode(Check_Report): + """Contains the Linode Check's finding information.""" + + resource_name: str + resource_id: str + region: str + + def __init__( + self, + metadata: Dict, + resource: Any, + resource_name: str, + resource_id: str, + region: str = "global", + ) -> None: + """Initialize the Linode Check's finding information. + + Args: + metadata: The metadata of the check. + resource: Basic information about the resource. + resource_name: The name of the resource related with the finding. + resource_id: The id of the resource related with the finding. + region: The region of the resource related with the finding. + """ + super().__init__(metadata, resource) + self.resource_name = resource_name + self.resource_id = resource_id + self.region = region + + @dataclass class CheckReportM365(Check_Report): """Contains the M365 Check's finding information.""" diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 8f05fc30cb..b65a702fbc 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -15,6 +15,8 @@ from prowler.lib.check.models import Severity from prowler.lib.cli.redact import warn_sensitive_argument_values from prowler.lib.outputs.common import Status from prowler.providers.common.arguments import ( + PROVIDER_ALIASES, + enforce_invoked_provider_loaded, init_providers_parser, validate_asff_usage, validate_provider_arguments, @@ -49,6 +51,7 @@ class ProwlerArgumentParser: "okta", "scaleway", "stackit", + "linode", } all_providers = set(Provider.get_available_providers()) new_providers = sorted(all_providers - known_providers) @@ -71,10 +74,10 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...", + usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,dashboard,iac,image,llm{extra_providers_csv}}} ...", epilog=f""" Available Cloud Providers: - {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}} + {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -94,7 +97,8 @@ Available Cloud Providers: nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider scaleway Scaleway Provider - vercel Vercel Provider{extra_providers_text} + vercel Vercel Provider + linode Linode Provider{extra_providers_text} Available components: @@ -166,13 +170,13 @@ Detailed documentation at https://docs.prowler.com if sys.argv[1].startswith("-"): sys.argv = self.__set_default_provider__(sys.argv) - # Provider aliases mapping - # Microsoft 365 - elif sys.argv[1] == "microsoft365": - sys.argv[1] = "m365" - # Oracle Cloud Infrastructure - elif sys.argv[1] == "oci": - sys.argv[1] = "oraclecloud" + # Provider aliases mapping (single source: arguments.PROVIDER_ALIASES) + elif sys.argv[1] in PROVIDER_ALIASES: + sys.argv[1] = PROVIDER_ALIASES[sys.argv[1]] + + # Selective fail-loud here (post argv-normalisation, pre parse_args) + # so the invoked-provider check stays correct under parse(args=...). + enforce_invoked_provider_loaded(self) # Warn about sensitive flags passed with explicit values # Snapshot argv before parse_args() which may exit on errors diff --git a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py index 8afb636545..0d47ea43b5 100644 --- a/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py +++ b/prowler/lib/outputs/compliance/asd_essential_eight/asd_essential_eight.py @@ -27,6 +27,8 @@ def get_asd_essential_eight_table( pass_count = [] fail_count = [] muted_count = [] + section_seen = {} + provider = "" # The applied config is scan-global (the provider's audit_config). Evaluate # each requirement's config constraints once against it (memoised by Id). audit_config = get_scan_audit_config() @@ -36,6 +38,7 @@ def get_asd_essential_eight_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ASD-Essential-Eight": + provider = compliance.Provider for requirement in compliance.Requirements: # A requirement whose configurable checks ran with an invalid # config can't be trusted: treat the finding as FAIL. @@ -53,21 +56,33 @@ def get_asd_essential_eight_table( "PASS": 0, "Muted": 0, } + section_seen[section] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if effective_status == "FAIL" and index not in fail_count: + elif effective_status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif effective_status == "PASS" and index not in pass_count: + elif effective_status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif effective_status == "FAIL": + sections[section]["FAIL"] += 1 + elif effective_status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - asd_essential_eight_compliance_table["Provider"].append(compliance.Provider) + asd_essential_eight_compliance_table["Provider"].append(provider) asd_essential_eight_compliance_table["Section"].append(section) if sections[section]["FAIL"] > 0: asd_essential_eight_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/c5/c5.py b/prowler/lib/outputs/compliance/c5/c5.py index 1417c50e39..5f3d3b2f80 100644 --- a/prowler/lib/outputs/compliance/c5/c5.py +++ b/prowler/lib/outputs/compliance/c5/c5.py @@ -27,6 +27,8 @@ def get_c5_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" # The applied config is scan-global (the provider's audit_config). Evaluate # each requirement's config constraints once against it (memoised by Id). audit_config = get_scan_audit_config() @@ -36,6 +38,7 @@ def get_c5_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "C5": + provider = compliance.Provider for requirement in compliance.Requirements: # A requirement whose configurable checks ran with an invalid # config can't be trusted: treat the finding as FAIL. @@ -50,22 +53,33 @@ def get_c5_table( if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if effective_status == "FAIL" and index not in fail_count: + elif effective_status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif effective_status == "PASS" and index not in pass_count: + elif effective_status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif effective_status == "FAIL": + sections[section]["FAIL"] += 1 + elif effective_status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/ccc/ccc.py b/prowler/lib/outputs/compliance/ccc/ccc.py index a829aa70d7..547319bd38 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc.py +++ b/prowler/lib/outputs/compliance/ccc/ccc.py @@ -27,6 +27,8 @@ def get_ccc_table( fail_count = [] muted_count = [] sections = {} + section_seen = {} + provider = "" # The applied config is scan-global (the provider's audit_config). Evaluate # each requirement's config constraints once against it (memoised by Id). audit_config = get_scan_audit_config() @@ -36,6 +38,7 @@ def get_ccc_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "CCC": + provider = compliance.Provider for requirement in compliance.Requirements: # A requirement whose configurable checks ran with an invalid # config can't be trusted: treat the finding as FAIL. @@ -50,22 +53,33 @@ def get_ccc_table( if section not in sections: sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if effective_status == "FAIL" and index not in fail_count: + elif effective_status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["FAIL"] += 1 - elif effective_status == "PASS" and index not in pass_count: + elif effective_status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif effective_status == "FAIL": + sections[section]["FAIL"] += 1 + elif effective_status == "PASS": sections[section]["PASS"] += 1 sections = dict(sorted(sections.items())) for section in sections: - section_table["Provider"].append(compliance.Provider) + section_table["Provider"].append(provider) section_table["Section"].append(section) if sections[section]["FAIL"] > 0: section_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/cis/cis.py b/prowler/lib/outputs/compliance/cis/cis.py index eb92978451..a76d79d792 100644 --- a/prowler/lib/outputs/compliance/cis/cis.py +++ b/prowler/lib/outputs/compliance/cis/cis.py @@ -18,6 +18,9 @@ def get_cis_table( compliance_overview: bool, ): sections = {} + section_muted_seen = {} + section_split_seen = {} + provider = "" cis_compliance_table = { "Provider": [], "Section": [], @@ -38,6 +41,7 @@ def get_cis_table( for compliance in check_compliances: version_in_name = compliance_framework.split("_")[1] if compliance.Framework == "CIS" and version_in_name in compliance.Version: + provider = compliance.Provider for requirement in compliance.Requirements: # A requirement whose configurable checks ran with an invalid # config can't be trusted: treat the finding as FAIL. @@ -57,9 +61,19 @@ def get_cis_table( "Level 2": {"FAIL": 0, "PASS": 0}, "Muted": 0, } + section_muted_seen[section] = set() + section_split_seen[section] = { + "Level 1": set(), + "Level 2": set(), + } if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-section Muted: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_muted_seen[section]: + section_muted_seen[section].add(index) sections[section]["Muted"] += 1 else: if effective_status == "FAIL" and index not in fail_count: @@ -67,13 +81,21 @@ def get_cis_table( elif effective_status == "PASS" and index not in pass_count: pass_count.append(index) if "Level 1" in attribute.Profile: - if not finding.muted: + if ( + not finding.muted + and index not in section_split_seen[section]["Level 1"] + ): + section_split_seen[section]["Level 1"].add(index) if effective_status == "FAIL": sections[section]["Level 1"]["FAIL"] += 1 else: sections[section]["Level 1"]["PASS"] += 1 elif "Level 2" in attribute.Profile: - if not finding.muted: + if ( + not finding.muted + and index not in section_split_seen[section]["Level 2"] + ): + section_split_seen[section]["Level 2"].add(index) if effective_status == "FAIL": sections[section]["Level 2"]["FAIL"] += 1 else: @@ -82,7 +104,7 @@ def get_cis_table( # Add results to table sections = dict(sorted(sections.items())) for section in sections: - cis_compliance_table["Provider"].append(compliance.Provider) + cis_compliance_table["Provider"].append(provider) cis_compliance_table["Section"].append(section) if sections[section]["Level 1"]["FAIL"] > 0: cis_compliance_table["Level 1"].append( diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index 65ad8af0b3..4e4bd78232 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -18,6 +18,9 @@ from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( get_mitre_attack_table, ) +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( get_prowler_threatscore_table, ) @@ -252,6 +255,15 @@ def display_compliance_table( output_directory, compliance_overview, ) + elif compliance_framework.startswith("okta_idaas_stig"): + get_okta_idaas_stig_table( + findings, + bulk_checks_metadata, + compliance_framework, + output_filename, + output_directory, + compliance_overview, + ) else: # Try provider-specific table first, fall back to generic from prowler.providers.common.provider import Provider diff --git a/prowler/lib/outputs/compliance/ens/ens.py b/prowler/lib/outputs/compliance/ens/ens.py index 8747731dc0..9e95a0cc7a 100644 --- a/prowler/lib/outputs/compliance/ens/ens.py +++ b/prowler/lib/outputs/compliance/ens/ens.py @@ -18,6 +18,8 @@ def get_ens_table( compliance_overview: bool, ): marcos = {} + marco_muted_seen = {} + provider = "" ens_compliance_table = { "Proveedor": [], "Marco/Categoria": [], @@ -40,6 +42,7 @@ def get_ens_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ENS": + provider = compliance.Provider for requirement in compliance.Requirements: # A requirement whose configurable checks ran with an invalid # config can't be trusted: treat the finding as FAIL. @@ -61,17 +64,23 @@ def get_ens_table( "Bajo": 0, "Muted": 0, } + marco_muted_seen[marco_categoria] = set() if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-marco Muted: count each finding once per marco + # it belongs to (a finding can map to several marcos). + if index not in marco_muted_seen[marco_categoria]: + marco_muted_seen[marco_categoria].add(index) marcos[marco_categoria]["Muted"] += 1 else: if effective_status == "FAIL": - if ( - attribute.Tipo != "recomendacion" - and index not in fail_count - ): - fail_count.append(index) + if attribute.Tipo != "recomendacion": + if index not in fail_count: + fail_count.append(index) + # Mark every marco the finding belongs to as + # NO CUMPLE, not just the first one seen. marcos[marco_categoria][ "Estado" ] = f"{Fore.RED}NO CUMPLE{Style.RESET_ALL}" @@ -88,7 +97,7 @@ def get_ens_table( # Add results to table for marco in sorted(marcos): - ens_compliance_table["Proveedor"].append(compliance.Provider) + ens_compliance_table["Proveedor"].append(provider) ens_compliance_table["Marco/Categoria"].append(marco) ens_compliance_table["Estado"].append(marcos[marco]["Estado"]) ens_compliance_table["Opcional"].append( diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py index a83f2db793..c6e8a6b0ed 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp.py @@ -18,7 +18,9 @@ def get_kisa_ismsp_table( compliance_overview: bool, ): sections = {} + section_seen = {} sections_status = {} + provider = "" kisa_ismsp_compliance_table = { "Provider": [], "Section": [], @@ -40,6 +42,7 @@ def get_kisa_ismsp_table( compliance.Framework.startswith("KISA") and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: # A requirement whose configurable checks ran with an invalid # config can't be trusted: treat the finding as FAIL. @@ -60,16 +63,28 @@ def get_kisa_ismsp_table( }, "Muted": 0, } + section_seen[section] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - sections[section]["Muted"] += 1 - else: - if effective_status == "FAIL" and index not in fail_count: + elif effective_status == "FAIL": + if index not in fail_count: fail_count.append(index) - sections[section]["Status"]["FAIL"] += 1 - elif effective_status == "PASS" and index not in pass_count: + elif effective_status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif effective_status == "FAIL": + sections[section]["Status"]["FAIL"] += 1 + elif effective_status == "PASS": sections[section]["Status"]["PASS"] += 1 # Add results to table @@ -87,7 +102,7 @@ def get_kisa_ismsp_table( else: sections_status[section] = f"{Fore.GREEN}PASS{Style.RESET_ALL}" for section in sections: - kisa_ismsp_compliance_table["Provider"].append(compliance.Provider) + kisa_ismsp_compliance_table["Provider"].append(provider) kisa_ismsp_compliance_table["Section"].append(section) kisa_ismsp_compliance_table["Status"].append(sections_status[section]) kisa_ismsp_compliance_table["Muted"].append( diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py index 81474cdda1..5fc56b251c 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack.py @@ -18,6 +18,8 @@ def get_mitre_attack_table( compliance_overview: bool, ): tactics = {} + tactic_seen = {} + provider = "" mitre_compliance_table = { "Provider": [], "Tactic": [], @@ -39,6 +41,7 @@ def get_mitre_attack_table( "MITRE-ATTACK" in compliance.Framework and compliance.Version in compliance_framework ): + provider = compliance.Provider for requirement in compliance.Requirements: # A requirement whose configurable checks ran with an invalid # config can't be trusted: treat the finding as FAIL. @@ -51,23 +54,33 @@ def get_mitre_attack_table( for tactic in requirement.Tactics: if tactic not in tactics: tactics[tactic] = {"FAIL": 0, "PASS": 0, "Muted": 0} + tactic_seen[tactic] = set() + + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) + elif effective_status == "FAIL": + if index not in fail_count: + fail_count.append(index) + elif effective_status == "PASS": + if index not in pass_count: + pass_count.append(index) + + # Per-tactic counts: count each finding once per tactic + # it belongs to (a finding can map to several tactics). + if index not in tactic_seen[tactic]: + tactic_seen[tactic].add(index) + if finding.muted: tactics[tactic]["Muted"] += 1 - else: - if effective_status == "FAIL": - if index not in fail_count: - fail_count.append(index) - tactics[tactic]["FAIL"] += 1 + elif effective_status == "FAIL": + tactics[tactic]["FAIL"] += 1 elif effective_status == "PASS": - if index not in pass_count: - pass_count.append(index) - tactics[tactic]["PASS"] += 1 + tactics[tactic]["PASS"] += 1 # Add results to table tactics = dict(sorted(tactics.items())) for tactic in tactics: - mitre_compliance_table["Provider"].append(compliance.Provider) + mitre_compliance_table["Provider"].append(provider) mitre_compliance_table["Tactic"].append(tactic) if tactics[tactic]["FAIL"] > 0: mitre_compliance_table["Status"].append( diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py b/prowler/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/models.py b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py new file mode 100644 index 0000000000..674d9656b7 --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/models.py @@ -0,0 +1,32 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + + +class OktaIDaaSSTIGModel(BaseModel): + """ + OktaIDaaSSTIGModel generates a finding's output in DISA Okta IDaaS STIG Compliance format. + """ + + Provider: str + Description: str + OrganizationDomain: str + AssessmentDate: str + Requirements_Id: str + Requirements_Name: str + Requirements_Description: str + Requirements_Attributes_Section: str + Requirements_Attributes_Severity: str + Requirements_Attributes_RuleID: str + Requirements_Attributes_StigID: str + Requirements_Attributes_CCI: Optional[list[str]] = None + Requirements_Attributes_CheckText: Optional[str] = None + Requirements_Attributes_FixText: Optional[str] = None + Status: str + StatusExtended: str + ResourceId: str + ResourceName: str + CheckId: str + Muted: bool + Framework: str + Name: str diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py new file mode 100644 index 0000000000..1febe02f60 --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig.py @@ -0,0 +1,112 @@ +from colorama import Fore, Style +from tabulate import tabulate + +from prowler.config.config import orange_color + + +def get_okta_idaas_stig_table( + findings: list, + bulk_checks_metadata: dict, + compliance_framework: str, + output_filename: str, + output_directory: str, + compliance_overview: bool, +): + section_table = { + "Provider": [], + "Section": [], + "Status": [], + "Muted": [], + } + pass_count = [] + fail_count = [] + muted_count = [] + sections = {} + section_seen = {} + provider = "" + for index, finding in enumerate(findings): + check = bulk_checks_metadata[finding.check_metadata.CheckID] + check_compliances = check.Compliance + for compliance in check_compliances: + if compliance.Framework == "Okta-IDaaS-STIG": + provider = compliance.Provider + for requirement in compliance.Requirements: + for attribute in requirement.Attributes: + section = attribute.Section + + if section not in sections: + sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} + section_seen[section] = set() + + # Overview totals: count each finding once per framework + if finding.muted: + if index not in muted_count: + muted_count.append(index) + elif finding.status == "FAIL": + if index not in fail_count: + fail_count.append(index) + elif finding.status == "PASS": + if index not in pass_count: + pass_count.append(index) + + # Per-section counts: count each finding once per section + # it belongs to (a finding can map to several sections). + if index not in section_seen[section]: + section_seen[section].add(index) + if finding.muted: + sections[section]["Muted"] += 1 + elif finding.status == "FAIL": + sections[section]["FAIL"] += 1 + elif finding.status == "PASS": + sections[section]["PASS"] += 1 + + sections = dict(sorted(sections.items())) + for section in sections: + section_table["Provider"].append(provider) + section_table["Section"].append(section) + if sections[section]["FAIL"] > 0: + section_table["Status"].append( + f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}" + ) + else: + if sections[section]["PASS"] > 0: + section_table["Status"].append( + f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}" + ) + else: + section_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") + section_table["Muted"].append( + f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}" + ) + + if ( + len(fail_count) + len(pass_count) + len(muted_count) > 1 + ): # If there are no resources, don't print the compliance table + print( + f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:" + ) + total_findings_count = len(fail_count) + len(pass_count) + len(muted_count) + overview_table = [ + [ + f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}", + f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}", + f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}", + ] + ] + print(tabulate(overview_table, tablefmt="rounded_grid")) + if not compliance_overview: + if len(fail_count) > 0 and len(section_table["Section"]) > 0: + print( + f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:" + ) + print( + tabulate( + section_table, + tablefmt="rounded_grid", + headers="keys", + ) + ) + print(f"\nDetailed results of {compliance_framework.upper()} are in:") + print( + f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n" + ) diff --git a/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py new file mode 100644 index 0000000000..25f71b4def --- /dev/null +++ b/prowler/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta.py @@ -0,0 +1,95 @@ +from prowler.config.config import timestamp +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput +from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel +from prowler.lib.outputs.finding import Finding + + +class OktaIDaaSSTIG(ComplianceOutput): + """ + This class represents the Okta IDaaS STIG compliance output. + + Attributes: + - _data (list): A list to store transformed data from findings. + - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. + + Methods: + - transform: Transforms findings into Okta IDaaS STIG compliance format. + """ + + def transform( + self, + findings: list[Finding], + compliance: Compliance, + _compliance_name: str, + ) -> None: + """ + Transforms a list of findings into Okta IDaaS STIG compliance format. + + Parameters: + - findings (list): A list of findings. + - compliance (Compliance): A compliance model. + - _compliance_name (str): The name of the compliance model (unused). + + Returns: + - None + """ + for finding in findings: + for requirement in compliance.Requirements: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: + for attribute in requirement.Attributes: + compliance_row = OktaIDaaSSTIGModel( + Provider=finding.provider, + Description=compliance.Description, + OrganizationDomain=finding.account_name, + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Name=requirement.Name, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_Severity=attribute.Severity.value, + Requirements_Attributes_RuleID=attribute.RuleID, + Requirements_Attributes_StigID=attribute.StigID, + Requirements_Attributes_CCI=attribute.CCI, + Requirements_Attributes_CheckText=attribute.CheckText, + Requirements_Attributes_FixText=attribute.FixText, + Status=finding.status, + StatusExtended=finding.status_extended, + ResourceId=finding.resource_uid, + 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 = OktaIDaaSSTIGModel( + Provider=compliance.Provider.lower(), + Description=compliance.Description, + OrganizationDomain="", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Name=requirement.Name, + Requirements_Description=requirement.Description, + Requirements_Attributes_Section=attribute.Section, + Requirements_Attributes_Severity=attribute.Severity.value, + Requirements_Attributes_RuleID=attribute.RuleID, + Requirements_Attributes_StigID=attribute.StigID, + Requirements_Attributes_CCI=attribute.CCI, + Requirements_Attributes_CheckText=attribute.CheckText, + Requirements_Attributes_FixText=attribute.FixText, + Status="MANUAL", + StatusExtended="Manual check", + ResourceId="manual_check", + 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/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py index 0321cb126f..1c6b879a71 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py @@ -29,6 +29,8 @@ def get_prowler_threatscore_table( fail_count = [] muted_count = [] pillars = {} + pillar_seen = {} + provider = "" generic_score = 0 max_generic_score = 0 counted_findings_generic = [] @@ -44,6 +46,7 @@ def get_prowler_threatscore_table( check_compliances = check.Compliance for compliance in check_compliances: if compliance.Framework == "ProwlerThreatScore": + provider = compliance.Provider for requirement in compliance.Requirements: # A requirement whose configurable checks ran with an invalid # config can't be trusted: treat the finding as FAIL (it stops @@ -83,17 +86,28 @@ def get_prowler_threatscore_table( if pillar not in pillars: pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0} + pillar_seen[pillar] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - pillars[pillar]["Muted"] += 1 - else: - if effective_status == "FAIL" and index not in fail_count: + elif effective_status == "FAIL": + if index not in fail_count: fail_count.append(index) - pillars[pillar]["FAIL"] += 1 - elif effective_status == "PASS" and index not in pass_count: + elif effective_status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-pillar counts: count each finding once per pillar + # it belongs to (a finding can map to several pillars). + if index not in pillar_seen[pillar]: + pillar_seen[pillar].add(index) + if finding.muted: + pillars[pillar]["Muted"] += 1 + elif effective_status == "FAIL": + pillars[pillar]["FAIL"] += 1 + elif effective_status == "PASS": pillars[pillar]["PASS"] += 1 # Generic score @@ -108,18 +122,21 @@ def get_prowler_threatscore_table( counted_findings_generic.append(index) no_findings_pillars = [] - bulk_compliance = Compliance.get_bulk(provider=compliance.Provider.lower()).get( - compliance_framework + bulk_compliance = ( + Compliance.get_bulk(provider=provider.lower()).get(compliance_framework) + if provider + else None ) - for requirement in bulk_compliance.Requirements: - for attribute in requirement.Attributes: - pillar = attribute.Section - if pillar not in pillars.keys() and pillar not in no_findings_pillars: - no_findings_pillars.append(pillar) + if bulk_compliance: + for requirement in bulk_compliance.Requirements: + for attribute in requirement.Attributes: + pillar = attribute.Section + if pillar not in pillars.keys() and pillar not in no_findings_pillars: + no_findings_pillars.append(pillar) pillars = dict(sorted(pillars.items())) for pillar in pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) if max_score_per_pillar[pillar] == 0: pillar_score = 100.0 @@ -145,7 +162,7 @@ def get_prowler_threatscore_table( ) for pillar in no_findings_pillars: - pillar_table["Provider"].append(compliance.Provider) + pillar_table["Provider"].append(provider) pillar_table["Pillar"].append(pillar) pillar_table["Score"].append(f"{Style.BRIGHT}{Fore.GREEN}100%{Style.RESET_ALL}") pillar_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}") diff --git a/prowler/lib/outputs/compliance/universal/universal_table.py b/prowler/lib/outputs/compliance/universal/universal_table.py index 73b0d4d512..f9165626fe 100644 --- a/prowler/lib/outputs/compliance/universal/universal_table.py +++ b/prowler/lib/outputs/compliance/universal/universal_table.py @@ -168,6 +168,7 @@ def _render_grouped( """Grouped mode: one row per group with pass/fail counts.""" check_map = _build_requirement_check_map(framework, provider) groups = {} + group_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -191,17 +192,28 @@ def _render_grouped( for group_key in _get_group_key(req, group_by): if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = set() + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - groups[group_key]["Muted"] += 1 - else: - if effective_status == "FAIL" and index not in fail_count: + elif effective_status == "FAIL": + if index not in fail_count: fail_count.append(index) - groups[group_key]["FAIL"] += 1 - elif effective_status == "PASS" and index not in pass_count: + elif effective_status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-group counts: count each finding once per group it belongs + # to (a finding can map to several groups via several requirements). + if index not in group_seen[group_key]: + group_seen[group_key].add(index) + if finding.muted: + groups[group_key]["Muted"] += 1 + elif effective_status == "FAIL": + groups[group_key]["FAIL"] += 1 + elif effective_status == "PASS": groups[group_key]["PASS"] += 1 if not _print_overview( @@ -273,6 +285,8 @@ def _render_split( split_field = split_by.field split_values = split_by.values groups = {} + group_muted_seen = {} + group_split_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -299,12 +313,19 @@ def _render_split( sv: {"FAIL": 0, "PASS": 0} for sv in split_values } groups[group_key]["Muted"] = 0 + group_muted_seen[group_key] = set() + group_split_seen[group_key] = {sv: set() for sv in split_values} split_val = req.attributes.get(split_field, "") if finding.muted: + # Overview total: count each finding once per framework if index not in muted_count: muted_count.append(index) + # Per-group Muted: count each finding once per group it + # belongs to (a finding can map to several groups). + if index not in group_muted_seen[group_key]: + group_muted_seen[group_key].add(index) groups[group_key]["Muted"] += 1 else: if effective_status == "FAIL" and index not in fail_count: @@ -314,7 +335,8 @@ def _render_split( for sv in split_values: if sv in str(split_val): - if not finding.muted: + if index not in group_split_seen[group_key][sv]: + group_split_seen[group_key][sv].add(index) if effective_status == "FAIL": groups[group_key][sv]["FAIL"] += 1 else: @@ -389,6 +411,7 @@ def _render_scored( risk_field = scoring.risk_field weight_field = scoring.weight_field groups = {} + group_seen = {} pass_count = [] fail_count = [] muted_count = [] @@ -423,6 +446,7 @@ def _render_scored( if group_key not in groups: groups[group_key] = {"FAIL": 0, "PASS": 0, "Muted": 0} + group_seen[group_key] = set() score_per_group[group_key] = 0 max_score_per_group[group_key] = 0 counted_per_group[group_key] = [] @@ -433,16 +457,26 @@ def _render_scored( max_score_per_group[group_key] += risk * weight counted_per_group[group_key].append(index) + # Overview totals: count each finding once per framework if finding.muted: if index not in muted_count: muted_count.append(index) - groups[group_key]["Muted"] += 1 - else: - if effective_status == "FAIL" and index not in fail_count: + elif effective_status == "FAIL": + if index not in fail_count: fail_count.append(index) - groups[group_key]["FAIL"] += 1 - elif effective_status == "PASS" and index not in pass_count: + elif effective_status == "PASS": + if index not in pass_count: pass_count.append(index) + + # Per-group counts: count each finding once per group it belongs + # to (a finding can map to several groups via several requirements). + if index not in group_seen[group_key]: + group_seen[group_key].add(index) + if finding.muted: + groups[group_key]["Muted"] += 1 + elif effective_status == "FAIL": + groups[group_key]["FAIL"] += 1 + elif effective_status == "PASS": groups[group_key]["PASS"] += 1 if index not in counted_generic and not finding.muted: diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 9f772d17c4..ed8bda4039 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -468,6 +468,24 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.region + elif provider.type == "linode": + output_data["auth_method"] = "api_token" + # account_uid is a required string, but the account ID may be + # unavailable when the token lacks account:read_only scope. Fall + # back to the username/email so findings are never dropped. + output_data["account_uid"] = ( + get_nested_attribute(provider, "identity.account_id") + or get_nested_attribute(provider, "identity.username") + or get_nested_attribute(provider, "identity.email") + or "linode" + ) + output_data["account_name"] = get_nested_attribute( + provider, "identity.username" + ) or get_nested_attribute(provider, "identity.email") + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_id + output_data["region"] = check_output.region + elif provider.type == "alibabacloud": output_data["auth_method"] = get_nested_attribute( provider, "identity.identity_arn" diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 52afd071ae..18dd4b0f3d 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -73,8 +73,7 @@ class HTML(Output): elif finding.status == "FAIL": row_class = "table-danger" - self._data.append( - f""" + self._data.append(f""" {finding_status} {finding.metadata.Severity.value} @@ -89,8 +88,7 @@ class HTML(Output):

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

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

- """ - ) + """) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -143,8 +141,7 @@ class HTML(Output): from_cli (bool): whether the request is from the CLI or not """ try: - file_descriptor.write( - f""" + file_descriptor.write(f""" @@ -253,8 +250,7 @@ class HTML(Output): Compliance - """ - ) + """) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" @@ -269,8 +265,7 @@ class HTML(Output): file_descriptor (file): the file descriptor to write the footer """ try: - file_descriptor.write( - """ + file_descriptor.write(""" @@ -409,8 +404,7 @@ class HTML(Output): -""" - ) +""") except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" @@ -1588,6 +1582,63 @@ class HTML(Output): ) return "" + @staticmethod + def get_linode_assessment_summary(provider: Provider) -> str: + """ + get_linode_assessment_summary gets the HTML assessment summary for the Linode provider + + Args: + provider (Provider): the Linode provider object + + Returns: + str: HTML assessment summary for the Linode provider + """ + try: + username = getattr(provider.identity, "username", None) or "-" + email = getattr(provider.identity, "email", None) or "-" + account_id = getattr(provider.identity, "account_id", None) or "-" + + assessment_items = f""" +
  • + Account ID: {account_id} +
  • +
  • + Username: {username} +
  • +
  • + Email: {email} +
  • """ + + credentials_items = """ +
  • + Authentication: API Token +
  • """ + + return f""" +
    +
    +
    + Linode Assessment Summary +
    +
      {assessment_items} +
    +
    +
    +
    +
    +
    + Linode 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 f7f31666e1..9005b5274e 100644 --- a/prowler/lib/outputs/jira/jira.py +++ b/prowler/lib/outputs/jira/jira.py @@ -341,6 +341,7 @@ class Jira: } TOKEN_URL = "https://auth.atlassian.com/oauth/token" API_TOKEN_URL = "https://api.atlassian.com/oauth/token/accessible-resources" + REQUEST_TIMEOUT = 90 HEADER_TEMPLATE = { "Content-Type": "application/json", "X-Force-Accept-Language": "true", @@ -578,7 +579,12 @@ class Jira: } headers = self.get_headers(content_type_json=True) - response = requests.post(self.TOKEN_URL, json=body, headers=headers) + response = requests.post( + self.TOKEN_URL, + json=body, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: tokens = response.json() @@ -630,12 +636,17 @@ class Jira: response = requests.get( f"https://{domain}.atlassian.net/_edge/tenant_info", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) response = response.json() return response.get("cloudId") else: headers = self.get_headers(access_token) - response = requests.get(self.API_TOKEN_URL, headers=headers) + response = requests.get( + self.API_TOKEN_URL, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: resources = response.json() @@ -717,7 +728,12 @@ class Jira: } headers = self.get_headers(content_type_json=True) - response = requests.post(url, json=body, headers=headers) + response = requests.post( + url, + json=body, + headers=headers, + timeout=self.REQUEST_TIMEOUT, + ) if response.status_code == 200: tokens = response.json() @@ -874,6 +890,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: @@ -941,6 +958,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project_key}&expand=projects.issuetypes.fields", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: @@ -986,6 +1004,7 @@ class Jira: response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/project", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code == 200: projects_data = {} @@ -1001,6 +1020,7 @@ class Jira: project_response = requests.get( f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue/createmeta?projectKeys={project['key']}&expand=projects.issuetypes.fields", headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if project_response.status_code == 200: project_metadata = project_response.json() @@ -1923,6 +1943,7 @@ class Jira: f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", json=payload, headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code != 201: @@ -2127,6 +2148,7 @@ class Jira: f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/3/issue", json=payload, headers=headers, + timeout=self.REQUEST_TIMEOUT, ) if response.status_code != 201: diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index a1f37a9dfc..40dc4635ba 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -46,6 +46,8 @@ def stdout_report(finding, color, verbose, status, fix, provider=None): details = finding.region elif finding.check_metadata.Provider == "scaleway": details = finding.region + elif finding.check_metadata.Provider == "linode": + details = finding.region else: # Dynamic fallback: any external/custom provider if provider is None: diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index 43d4a547c1..77b4c2725a 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -121,6 +121,11 @@ def display_summary_table( elif provider.type == "scaleway": entity_type = "Organization" audited_entities = provider.identity.organization_id + elif provider.type == "linode": + entity_type = "Account" + audited_entities = ( + provider.identity.username or provider.identity.email or "linode" + ) else: # Dynamic fallback: any external/custom provider entity_type, audited_entities = provider.get_summary_entity() diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 2e451910fb..0874d14a07 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -2582,6 +2582,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2591,6 +2592,9 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -2604,6 +2608,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -7344,6 +7349,7 @@ "lightsail": { "regions": { "aws": [ + "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-south-1", @@ -7354,9 +7360,11 @@ "ca-central-1", "eu-central-1", "eu-north-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -8269,7 +8277,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9220,6 +9230,7 @@ "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -9986,6 +9997,8 @@ "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "eu-central-1", "eu-south-2", diff --git a/prowler/providers/aws/lib/ip_ranges/__init__.py b/prowler/providers/aws/lib/ip_ranges/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/lib/ip_ranges/ip_ranges.py b/prowler/providers/aws/lib/ip_ranges/ip_ranges.py new file mode 100644 index 0000000000..3dbb9d1b17 --- /dev/null +++ b/prowler/providers/aws/lib/ip_ranges/ip_ranges.py @@ -0,0 +1,51 @@ +import json +import urllib.error +import urllib.request +from ipaddress import ip_network + +from prowler.lib.logger import logger + +AWS_IP_RANGES_URL = "https://ip-ranges.amazonaws.com/ip-ranges.json" +AWS_IP_RANGES_TIMEOUT = 10 + + +def get_public_ip_networks() -> list: + """Fetch the AWS public IP prefixes as a list of ip_network objects. + + The request verifies the server certificate against the system trust store, + matching urllib's default behaviour. This replaces the unmaintained + awsipranges package, whose latest release (0.3.3) calls + urllib.request.urlopen() with the cafile/capath arguments that Python 3.13 + removed. + + Returns an empty list when the feed cannot be fetched or parsed, and skips + individual malformed prefixes, so a transient or corrupt feed never aborts + the calling check. + """ + try: + with urllib.request.urlopen( + AWS_IP_RANGES_URL, timeout=AWS_IP_RANGES_TIMEOUT + ) as response: + ranges = json.loads(response.read()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return [] + + networks = [] + for key, prefixes in ( + ("ip_prefix", ranges.get("prefixes", [])), + ("ipv6_prefix", ranges.get("ipv6_prefixes", [])), + ): + for prefix in prefixes: + cidr = prefix.get(key) + if not cidr: + continue + try: + networks.append(ip_network(cidr)) + except ValueError as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return networks diff --git a/prowler/providers/aws/services/acmpca/__init__.py b/prowler/providers/aws/services/acmpca/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/__init__.py b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json new file mode 100644 index 0000000000..6915540539 --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "acmpca_certificate_authority_pqc_key_algorithm", + "CheckTitle": "AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "acmpca", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsAcmPcaCertificateAuthority", + "ResourceGroup": "security", + "Description": "**AWS Private Certificate Authorities (Private CAs)** are assessed for use of a **post-quantum digital signature key algorithm** (`ML_DSA_44`, `ML_DSA_65`, `ML_DSA_87`). CAs that still issue certificates with RSA or ECC algorithms produce signatures vulnerable to forgery once a cryptographically relevant quantum computer is available.", + "Risk": "RSA and ECC signatures can be broken by Shor's algorithm on a sufficiently large quantum computer. A compromised CA private key would let an attacker issue arbitrary certificates trusted across the PKI, undermining identity and code-signing controls. Migrating CAs to **ML-DSA** (NIST FIPS 204) provides quantum-resistant signatures so issued certificates retain integrity in the post-quantum era.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/privateca/latest/userguide/PcaTerms.html", + "https://aws.amazon.com/about-aws/whats-new/2025/11/aws-private-ca-post-quantum-digital-certificates/", + "https://aws.amazon.com/blogs/security/post-quantum-ml-dsa-code-signing-with-aws-private-ca-and-aws-kms/", + "https://csrc.nist.gov/pubs/fips/204/final" + ], + "Remediation": { + "Code": { + "CLI": "aws acm-pca create-certificate-authority --certificate-authority-configuration '{\"KeyAlgorithm\":\"ML_DSA_65\",\"SigningAlgorithm\":\"ML_DSA_65\",\"Subject\":{...}}' --certificate-authority-type SUBORDINATE", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ACMPCA::CertificateAuthority\n Properties:\n Type: SUBORDINATE\n KeyAlgorithm: ML_DSA_65 # FIX: post-quantum signature algorithm\n SigningAlgorithm: ML_DSA_65\n Subject:\n CommonName: example-pqc-ca\n```", + "Other": "Existing CAs cannot have their key algorithm changed; create a new CA with KeyAlgorithm = ML_DSA_44 / ML_DSA_65 / ML_DSA_87, re-issue certificates from it, and decommission the legacy CA once dependent workloads have rotated.", + "Terraform": "```hcl\nresource \"aws_acmpca_certificate_authority\" \"\" {\n type = \"SUBORDINATE\"\n certificate_authority_configuration {\n key_algorithm = \"ML_DSA_65\" # FIX: post-quantum signature algorithm\n signing_algorithm = \"ML_DSA_65\"\n subject {\n common_name = \"example-pqc-ca\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Create new Private CAs with a **post-quantum key algorithm** (`ML_DSA_44`, `ML_DSA_65`, or `ML_DSA_87`) and migrate workloads off legacy RSA/ECC CAs. Plan crypto-agility for your PKI so that quantum-resistant trust anchors can be rolled out before threat actors gain access to a cryptographically relevant quantum computer.", + "Url": "https://hub.prowler.com/check/acmpca_certificate_authority_pqc_key_algorithm" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py new file mode 100644 index 0000000000..738875baee --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client + +PQC_PCA_KEY_ALGORITHMS_DEFAULT = [ + "ML_DSA_44", + "ML_DSA_65", + "ML_DSA_87", +] + + +class acmpca_certificate_authority_pqc_key_algorithm(Check): + """Verify that every AWS Private CA uses a post-quantum key algorithm. + + A Private CA PASSES when its ``KeyAlgorithm`` belongs to the configured + allowlist of post-quantum signature algorithms (ML-DSA family). + Deleted CAs are skipped. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check against AWS Private CAs. + + Returns: + A list of reports with each non-deleted CA PQC key algorithm status. + """ + + findings = [] + pqc_algorithms = acmpca_client.audit_config.get( + "acmpca_pqc_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT + ) + for ca in acmpca_client.certificate_authorities.values(): + if ca.status == "DELETED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=ca) + algorithm = ca.key_algorithm or "" + if ca.key_algorithm in pqc_algorithms: + report.status = "PASS" + report.status_extended = ( + f"AWS Private CA {ca.id} uses post-quantum key algorithm " + f"{algorithm}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"AWS Private CA {ca.id} uses key algorithm {algorithm}, " + "which is not post-quantum (ML-DSA)." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/acmpca/acmpca_client.py b/prowler/providers/aws/services/acmpca/acmpca_client.py new file mode 100644 index 0000000000..c8e22730bc --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_client.py @@ -0,0 +1,4 @@ +from prowler.providers.aws.services.acmpca.acmpca_service import ACMPCA +from prowler.providers.common.provider import Provider + +acmpca_client = ACMPCA(Provider.get_global_provider()) diff --git a/prowler/providers/aws/services/acmpca/acmpca_service.py b/prowler/providers/aws/services/acmpca/acmpca_service.py new file mode 100644 index 0000000000..9c6fb77096 --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_service.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any, Dict, List + +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered +from prowler.providers.aws.aws_provider import AwsProvider +from prowler.providers.aws.lib.service.service import AWSService + + +class ACMPCA(AWSService): + """AWS Private CA service class to list certificate authorities.""" + + def __init__(self, provider: AwsProvider) -> None: + """Initialize the AWS Private CA service. + + Args: + provider: AWS provider instance with session and audit context. + """ + + # The boto3 client identifier for AWS Private CA is "acm-pca" + super().__init__("acm-pca", provider) + self.certificate_authorities: dict[str, CertificateAuthority] = {} + self.__threading_call__(self._list_certificate_authorities) + + def _list_certificate_authorities(self, regional_client: Any) -> None: + """List AWS Private CAs and their tags in a region. + + Args: + regional_client: Regional AWS Private CA client. + """ + + logger.info("ACM PCA - Listing Certificate Authorities...") + try: + paginator = regional_client.get_paginator("list_certificate_authorities") + for page in paginator.paginate(): + for ca in page.get("CertificateAuthorities", []): + arn = ca.get("Arn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + config = ca.get("CertificateAuthorityConfiguration", {}) + tags = [] + try: + tags = regional_client.list_tags( + CertificateAuthorityArn=arn + ).get("Tags", []) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.certificate_authorities[arn] = CertificateAuthority( + arn=arn, + id=arn.split("/")[-1], + region=regional_client.region, + status=ca.get("Status", ""), + type=ca.get("Type", ""), + usage_mode=ca.get("UsageMode", ""), + key_algorithm=config.get("KeyAlgorithm", ""), + signing_algorithm=config.get("SigningAlgorithm", ""), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class CertificateAuthority(BaseModel): + """AWS Private Certificate Authority metadata. + + Attributes: + arn: Certificate authority ARN. + id: Certificate authority identifier. + region: AWS region where the certificate authority exists. + status: Certificate authority lifecycle status. + type: Certificate authority type. + usage_mode: Certificate authority usage mode. + key_algorithm: Key algorithm configured for the certificate authority. + signing_algorithm: Signing algorithm configured for the certificate authority. + tags: Tags attached to the certificate authority. + """ + + arn: str + id: str + region: str + status: str = "" + type: str = "" + usage_mode: str = "" + key_algorithm: str = "" + signing_algorithm: str = "" + tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/__init__.py b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json new file mode 100644 index 0000000000..276f6e1741 --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "apigateway_domain_name_pqc_tls_enabled", + "CheckTitle": "API Gateway custom domain names use a post-quantum TLS security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "apigateway", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsApiGatewayDomainName", + "ResourceGroup": "network", + "Description": "**API Gateway custom domain names** for REST APIs are assessed for use of a **post-quantum (PQ) TLS security policy** such as `SecurityPolicy_TLS13_1_2_PQ_2025_09`. Custom domains with legacy policies such as `TLS_1_0` or `TLS_1_2` lack hybrid ML-KEM key exchange, leaving captured traffic vulnerable to future quantum decryption.", + "Risk": "Without a PQ-ready TLS policy, traffic to API Gateway custom domains captured today can be decrypted once a **cryptographically relevant quantum computer** exists (**harvest-now, decrypt-later** attack). This threatens long-term **confidentiality** of API payloads, credentials, and bearer tokens.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-custom-domain-tls-version.html", + "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-security-policies-list.html", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws apigateway update-domain-name --domain-name --patch-operations op=replace,path=/securityPolicy,value=SecurityPolicy_TLS13_1_2_PQ_2025_09", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ApiGateway::DomainName\n Properties:\n DomainName: api.example.com\n RegionalCertificateArn: \n SecurityPolicy: SecurityPolicy_TLS13_1_2_PQ_2025_09 # FIX: enhanced post-quantum security policy\n EndpointConfiguration:\n Types:\n - REGIONAL\n```", + "Other": "1. In the AWS Console, go to API Gateway > Custom domain names\n2. Select the custom domain and choose Edit on Domain name configurations\n3. Set Security policy to SecurityPolicy_TLS13_1_2_PQ_2025_09 (post-quantum) and Endpoint access mode to Strict\n4. Save the changes", + "Terraform": "```hcl\nresource \"aws_api_gateway_domain_name\" \"\" {\n domain_name = \"api.example.com\"\n regional_certificate_arn = \"\"\n security_policy = \"SecurityPolicy_TLS13_1_2_PQ_2025_09\" # FIX: enhanced post-quantum security policy\n endpoint_configuration {\n types = [\"REGIONAL\"]\n }\n}\n```" + }, + "Recommendation": { + "Text": "Migrate every API Gateway custom domain name to an **enhanced post-quantum TLS policy** such as `SecurityPolicy_TLS13_1_2_PQ_2025_09` that enables hybrid ML-KEM key exchange. Note that you must also enable Strict endpoint access mode and that mutual TLS is not supported on enhanced policies. Review allowed policies regularly as AWS publishes new PQ-ready options.", + "Url": "https://hub.prowler.com/check/apigateway_domain_name_pqc_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "API Gateway HTTP and WebSocket APIs only support the legacy TLS_1_2 security policy and therefore cannot use post-quantum TLS today; this check evaluates REST API custom domain names only." +} diff --git a/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py new file mode 100644 index 0000000000..d9c2d071f9 --- /dev/null +++ b/prowler/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled.py @@ -0,0 +1,55 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.apigateway.apigateway_client import ( + apigateway_client, +) + +PQC_APIGATEWAY_POLICIES_DEFAULT = [ + "SecurityPolicy_TLS13_1_2_FIPS_PFS_PQ_2025_09", + "SecurityPolicy_TLS13_1_2_PFS_PQ_2025_09", + "SecurityPolicy_TLS13_1_2_PQ_2025_09", +] + + +def _get_allowed_policies(configured_policies: object) -> list[str]: + if not isinstance(configured_policies, list): + return PQC_APIGATEWAY_POLICIES_DEFAULT + + return configured_policies + + +class apigateway_domain_name_pqc_tls_enabled(Check): + """Verify that every API Gateway custom domain name uses a post-quantum TLS policy. + + A custom domain name PASSES when its ``securityPolicy`` belongs to the + configured allowlist of enhanced post-quantum policies. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the API Gateway custom domain post-quantum TLS check. + + Returns: + A list of reports for API Gateway custom domain names and their + post-quantum TLS policy compliance status. + """ + findings = [] + pqc_policies = _get_allowed_policies( + apigateway_client.audit_config.get("apigateway_pqc_tls_allowed_policies") + ) + for domain in apigateway_client.domain_names: + report = Check_Report_AWS(metadata=self.metadata(), resource=domain) + policy = domain.security_policy or "" + if domain.security_policy in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"API Gateway custom domain {domain.name} uses post-quantum " + f"TLS policy {policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"API Gateway custom domain {domain.name} uses TLS policy " + f"{policy}, which is not in the post-quantum allowlist." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/apigateway/apigateway_service.py b/prowler/providers/aws/services/apigateway/apigateway_service.py index 4f887a2da8..099551f440 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_service.py +++ b/prowler/providers/aws/services/apigateway/apigateway_service.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Optional from botocore.exceptions import ClientError from pydantic.v1 import BaseModel @@ -13,12 +13,45 @@ class APIGateway(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.rest_apis = [] + self.domain_names = [] self.__threading_call__(self._get_rest_apis) + self.__threading_call__(self._get_domain_names) self._get_authorizers() self._get_rest_api() self._get_stages() self._get_resources() + def _get_domain_names(self, regional_client: Any) -> None: + """Get API Gateway custom domain names for a regional client. + + Args: + regional_client: Regional API Gateway boto3 client used to list + custom domain names. + """ + logger.info("APIGateway - Getting custom domain names...") + try: + paginator = regional_client.get_paginator("get_domain_names") + for page in paginator.paginate(): + for item in page.get("items", []): + domain_name = item.get("domainName", "") + arn = f"arn:{self.audited_partition}:apigateway:{regional_client.region}::/domainnames/{domain_name}" + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + self.domain_names.append( + DomainName( + name=domain_name, + arn=arn, + region=regional_client.region, + security_policy=item.get("securityPolicy", ""), + tags=[item.get("tags", {})], + ) + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _get_rest_apis(self, regional_client): logger.info("APIGateway - Getting Rest APIs...") try: @@ -249,3 +282,21 @@ class RestAPI(BaseModel): stages: list[Stage] = [] tags: Optional[list] = [] resources: list[PathResourceMethods] = [] + + +class DomainName(BaseModel): + """API Gateway custom domain name metadata. + + Attributes: + name: Custom domain name. + arn: Custom domain name ARN. + region: AWS region where the custom domain name exists. + security_policy: TLS security policy configured for the custom domain. + tags: Custom domain tags. + """ + + name: str + arn: str + region: str + security_policy: str = "" + tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json new file mode 100644 index 0000000000..7625b6a626 --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "bedrock_agent_role_least_privilege", + "CheckTitle": "Amazon Bedrock agent execution role follows least privilege", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis", + "TTPs/Privilege Escalation" + ], + "ServiceName": "bedrock", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**Bedrock Agent** execution roles (`agentResourceRoleArn`) should grant only the minimum permissions the agent needs. The evaluation FAILs when the role has an AWS-managed `*FullAccess` policy attached, has an inline statement allowing broad actions on `Resource: \"*\"`, or has no permissions boundary configured.", + "Risk": "An overly permissive **Bedrock Agent** execution role turns a successful **prompt injection** into AWS privilege escalation. A model coerced into calling tools can invoke any API the role allows — reading secrets, modifying IAM, exfiltrating data from S3, or pivoting laterally. **Least privilege** plus a **permissions boundary** keeps the blast radius bounded even when guardrails fail.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege" + ], + "Remediation": { + "Code": { + "CLI": "aws iam put-role-permissions-boundary --role-name --permissions-boundary ", + "NativeIaC": "", + "Other": "1. Identify the Bedrock Agent's execution role (agentResourceRoleArn) in the IAM console\n2. Detach any AWS-managed *FullAccess policies (e.g. AmazonBedrockFullAccess, AdministratorAccess)\n3. Replace inline policies that use Resource: \"*\" with statements scoped to specific resource ARNs and minimal action sets\n4. Attach a permissions boundary that caps what the role can ever do, even if a future policy is added\n5. Re-run Prowler to confirm the check passes", + "Terraform": "```hcl\nresource \"aws_iam_role\" \"bedrock_agent\" {\n name = \"\"\n assume_role_policy = data.aws_iam_policy_document.trust.json\n permissions_boundary = aws_iam_policy.bedrock_agent_boundary.arn # CRITICAL: caps maximum privileges\n}\n\nresource \"aws_iam_role_policy\" \"bedrock_agent_inline\" {\n role = aws_iam_role.bedrock_agent.name\n policy = jsonencode({\n Version = \"2012-10-17\",\n Statement = [{\n Effect = \"Allow\",\n Action = [\"s3:GetObject\"], # CRITICAL: narrow action\n Resource = [\"arn:aws:s3:::my-rag-bucket/*\"] # CRITICAL: narrow resource\n }]\n })\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to every Bedrock Agent execution role: scope `Action` and `Resource` to exactly what the agent needs, avoid AWS-managed `*FullAccess` policies, and always attach a **permissions boundary** so that future policy edits cannot exceed an approved ceiling. Treat agent roles as high-risk because prompt injection can weaponize any granted permission.", + "Url": "https://hub.prowler.com/check/bedrock_agent_role_least_privilege" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py new file mode 100644 index 0000000000..e53183d90f --- /dev/null +++ b/prowler/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege.py @@ -0,0 +1,101 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.bedrock.bedrock_agent_client import ( + bedrock_agent_client, +) +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import check_admin_access +from prowler.providers.aws.services.iam.lib.privilege_escalation import ( + check_privilege_escalation, +) + + +class bedrock_agent_role_least_privilege(Check): + """Ensure Bedrock Agent execution roles follow least privilege. + + A Bedrock Agent's execution role is evaluated against three criteria: + - No AWS-managed ``*FullAccess`` policy attached. + - No attached or inline policy granting administrative access or known + privilege escalation combinations. + - A permissions boundary is configured on the role. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Run the least-privilege evaluation across all Bedrock Agents. + + Returns: + A list of ``Check_Report_AWS`` with one entry per agent. The + status is ``FAIL`` when any of the criteria above is violated, + or when the execution role cannot be resolved in IAM. + """ + findings = [] + roles_by_arn = {role.arn: role for role in (iam_client.roles or [])} + + for agent in bedrock_agent_client.agents.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=agent) + report.status = "PASS" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role follows least privilege." + ) + + role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None + if role is None: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role could not be " + f"resolved in IAM and cannot be evaluated for least privilege." + ) + findings.append(report) + continue + + violations = [] + + for policy in role.attached_policies: + policy_arn = policy.get("PolicyArn", "") + policy_name = policy.get("PolicyName") or policy_arn + if policy_arn.startswith( + "arn:aws:iam::aws:policy/" + ) and policy_arn.endswith("FullAccess"): + violations.append( + f"managed policy {policy_name} grants full access" + ) + continue + policy_obj = iam_client.policies.get(policy_arn) + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"managed policy {policy_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"managed policy {policy_name} allows privilege escalation" + ) + + for inline_name in role.inline_policies: + policy_obj = iam_client.policies.get(f"{role.arn}:policy/{inline_name}") + if policy_obj is None or not policy_obj.document: + continue + document = policy_obj.document + if check_admin_access(document): + violations.append( + f"inline policy {inline_name} grants administrative access" + ) + elif check_privilege_escalation(document): + violations.append( + f"inline policy {inline_name} allows privilege escalation" + ) + + if not role.permissions_boundary: + violations.append("no permissions boundary configured") + + if violations: + report.status = "FAIL" + report.status_extended = ( + f"Bedrock Agent {agent.name} execution role violates least " + f"privilege: {'; '.join(violations)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json index f9b8ee0df9..c85279e4a4 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "bedrock_api_key_no_long_term_credentials", - "CheckTitle": "Amazon Bedrock API key is expired", + "CheckTitle": "Amazon Bedrock long-term API key has expired", "CheckType": [ "Software and Configuration Checks/AWS Security Best Practices", "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices", @@ -14,23 +14,24 @@ "Severity": "high", "ResourceType": "AwsIamUser", "ResourceGroup": "IAM", - "Description": "**Bedrock API keys** are evaluated for **lifetime** and **expiration**.\n\nThe finding identifies keys that are long-lived, set to expire far in the future, or configured to `never expire`, and distinguishes them from keys that have already expired.", - "Risk": "Long-lived or non-expiring keys enable persistent access if compromised.\n- Confidentiality: unauthorized inference and exposure of prompts/outputs\n- Availability/Cost: uncontrolled usage and spend spikes\n- Integrity: actions can continue without timely revocation or rotation", + "Description": "AWS recommends Amazon Bedrock **long-term API keys** only for **exploration**; production workloads should use **short-term API keys** (session-scoped, valid up to **12 hours**). This check fails for any active long-term Bedrock API key, escalating to `critical` severity when configured to **never expire**. Already-expired keys pass — they can no longer authenticate.", + "Risk": "Long-term Bedrock API keys persist beyond a session until their stored expiration, and keys set to **never expire** grant indefinite access until manually revoked, enabling unauthorized inference, uncontrolled usage and spend, and activity that continues past timely revocation.", "RelatedUrl": "", "AdditionalURLs": [ - "https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/getting-started-api-keys.html", - "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials", - "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html", + "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-generate.html", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds-programmatic-access.html#security-creds-alternatives-to-long-term-access-keys", + "https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials" ], "Remediation": { "Code": { "CLI": "aws iam delete-service-specific-credential --user-name --service-specific-credential-id ", "NativeIaC": "", - "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select > Security credentials\n3. In \"API keys for Amazon Bedrock\", find the non-expired key and click Delete\n4. Confirm deletion to remove the key (removes the long-term credential so the check passes)", + "Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select the IAM user backing the Bedrock API key > Security credentials\n3. In \"API keys for Amazon Bedrock\", select the active long-term key and click Delete\n4. For workloads that still need Bedrock access, generate a short-term API key from the Bedrock console (Short-term API keys tab), or call the Bedrock API with short-term credentials issued by AWS STS", "Terraform": "" }, "Recommendation": { - "Text": "Prefer **short-term credentials** and **IAM roles**; avoid `never expire`.\n\nEnforce **least privilege**, strict **rotation**, and automatic **expiration** for any long-term key. Store secrets securely, monitor with audit logs, and revoke unused or stale keys quickly.", + "Text": "Use short-term Amazon Bedrock API keys for any non-exploratory workload — they are bound to the IAM principal's session, valid for at most 12 hours, scoped to a single Region, and can be auto-refreshed by the SDK. For existing long-term keys, delete the underlying IAM service-specific credential. If a long-term key must be retained for an exploration scenario, set an explicit short expiration and never select `never expire`.", "Url": "https://hub.prowler.com/check/bedrock_api_key_no_long_term_credentials" } }, @@ -40,5 +41,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "This check verifies that Amazon Bedrock API keys have expiration dates set. API keys without expiration dates are considered long-term credentials and pose a security risk. The check follows security best practices for credential management and the principle of least privilege." + "Notes": "AWS recommends against using long-term Amazon Bedrock API keys outside of exploration; production workloads should use short-term API keys (session-scoped, valid up to 12 hours). The IAM `ListServiceSpecificCredentials` API only enumerates long-term keys — short-term keys are session-scoped credentials that never appear here. The check therefore passes only when an existing long-term key has already expired and can no longer authenticate; any active long-term key fails, with critical severity when it is configured to never expire." } diff --git a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py index 2edffd7ccb..9697ecbff0 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py +++ b/prowler/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials.py @@ -1,49 +1,62 @@ from datetime import datetime, timezone -from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.lib.check.models import Check, Check_Report_AWS, Severity from prowler.providers.aws.services.iam.iam_client import iam_client +# Days threshold above which a Bedrock long-term API key is considered effectively non-expiring. +NEVER_EXPIRES_THRESHOLD_DAYS = 10000 + class bedrock_api_key_no_long_term_credentials(Check): - """ - Bedrock API keys should be short-lived to reduce the risk of unauthorized access. - This check verifies if there are any long-term Bedrock API keys. - If there are, it checks if they are expired or will be expired. - If they are expired, it will be marked as PASS. - If they are not expired, it will be marked as FAIL and the severity will be critical if the key will never expire. + """Amazon Bedrock long-term API keys should not be used outside of exploration. + + AWS recommends short-term Bedrock API keys (session-scoped, valid up to 12 hours) + for any non-exploratory workload. ``ListServiceSpecificCredentials`` only enumerates + long-term keys, so every key inspected here is by definition a long-term credential. + + PASS when the long-term key has already expired (it can no longer authenticate). + FAIL (critical) when the key is configured to never expire. + FAIL (high) for any other active long-term key. """ def execute(self): - """ - Execute the Bedrock API key no long-term credentials check. - - Iterate over all the Bedrock API keys and check if they are expired or will be expired. - - Returns: - List[Check_Report_AWS]: A list of report objects with the results of the check. - """ - findings = [] for api_key in iam_client.service_specific_credentials: if api_key.service_name != "bedrock.amazonaws.com": continue - if api_key.expiration_date: - report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) - # Check if the expiration date is in the future - if api_key.expiration_date > datetime.now(timezone.utc): - report.status = "FAIL" - # Get the days until the expiration date - days_until_expiration = ( - api_key.expiration_date - datetime.now(timezone.utc) - ).days - if days_until_expiration > 10000: - self.Severity = "critical" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and never expires." - else: - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and will expire in {days_until_expiration} days." - else: - report.status = "PASS" - report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists but has expired." - findings.append(report) + if not api_key.expiration_date: + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=api_key) + now = datetime.now(timezone.utc) + + if api_key.expiration_date <= now: + report.status = "PASS" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} has already expired and can no longer " + f"authenticate." + ) + elif (api_key.expiration_date - now).days > NEVER_EXPIRES_THRESHOLD_DAYS: + report.status = "FAIL" + report.check_metadata.Severity = Severity.critical + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is configured to never expire. Use " + f"short-term Bedrock API keys (session-scoped, valid up to " + f"12 hours) for non-exploratory workloads instead." + ) + else: + days_until_expiration = (api_key.expiration_date - now).days + report.status = "FAIL" + report.status_extended = ( + f"Bedrock long-term API key {api_key.id} in user " + f"{api_key.user.name} is active and will expire in " + f"{days_until_expiration} days. Use short-term Bedrock API " + f"keys (session-scoped, valid up to 12 hours) for " + f"non-exploratory workloads instead." + ) + + findings.append(report) return findings diff --git a/prowler/providers/aws/services/bedrock/bedrock_service.py b/prowler/providers/aws/services/bedrock/bedrock_service.py index 7222456341..aead63a67f 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_service.py +++ b/prowler/providers/aws/services/bedrock/bedrock_service.py @@ -146,6 +146,7 @@ class BedrockAgent(AWSService): self.prompts = {} self.prompt_scanned_regions: set = set() self.__threading_call__(self._list_agents) + self.__threading_call__(self._get_agent, self.agents.values()) self.__threading_call__(self._list_prompts) self.__threading_call__(self._get_prompt, self.prompts.values()) self.__threading_call__(self._list_tags_for_resource, self.agents.values()) @@ -174,6 +175,22 @@ class BedrockAgent(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_agent(self, agent): + """Fetch full agent details to capture the execution role ARN. + + list_agents only returns summaries (no agentResourceRoleArn), so we + need a per-agent GetAgent call. Stored on the Agent model for use by + checks like bedrock_agent_role_least_privilege. + """ + logger.info("Bedrock Agent - Getting Agent...") + try: + agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id) + agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn") + except Exception as error: + logger.error( + f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_prompts(self, regional_client): """List all prompts in a region.""" logger.info("Bedrock Agent - Listing Prompts...") @@ -236,6 +253,7 @@ class Agent(BaseModel): name: str arn: str guardrail_id: Optional[str] = None + role_arn: Optional[str] = None region: str tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/__init__.py b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json new file mode 100644 index 0000000000..465f4335c2 --- /dev/null +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "cloudfront_distributions_pqc_tls_enabled", + "CheckTitle": "CloudFront distributions enforce a post-quantum TLS 1.3 security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "cloudfront", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsCloudFrontDistribution", + "ResourceGroup": "network", + "Description": "**CloudFront distributions** are assessed for use of a **TLS 1.3-only security policy** (`TLSv1.3_2025`). CloudFront's quantum-safe key exchanges (`X25519MLKEM768`, `SecP256r1MLKEM768`) only work with TLS 1.3. Distributions that allow TLS 1.2 (or older) fallback to classical key exchanges and are exposed to `harvest-now, decrypt-later` attacks.", + "Risk": "Without a TLS 1.3-only policy, viewer traffic captured today can be decrypted once a **cryptographically relevant quantum computer** is available. Distributions using the default CloudFront certificate (`*.cloudfront.net`) cannot enable a post-quantum policy because they are pinned to the legacy `TLSv1` policy.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/secure-connections-supported-viewer-protocols-ciphers.html", + "https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/DownloadDistValuesGeneral.html#DownloadDistValues-security-policy", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws cloudfront update-distribution --id --distribution-config '{...\"ViewerCertificate\":{\"MinimumProtocolVersion\":\"TLSv1.3_2025\",...}}'", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::CloudFront::Distribution\n Properties:\n DistributionConfig:\n ViewerCertificate:\n AcmCertificateArn: \n SslSupportMethod: sni-only\n MinimumProtocolVersion: TLSv1.3_2025 # FIX: enforces TLS 1.3 + post-quantum KEX\n```", + "Other": "1. In the AWS Console, go to CloudFront > Distributions\n2. Select the distribution and open the General tab\n3. Choose Edit on Settings\n4. Set Custom SSL certificate (do not use the default *.cloudfront.net certificate)\n5. Set Security policy to TLSv1.3_2025\n6. Save changes", + "Terraform": "```hcl\nresource \"aws_cloudfront_distribution\" \"\" {\n # ...\n viewer_certificate {\n acm_certificate_arn = \"\"\n ssl_support_method = \"sni-only\"\n minimum_protocol_version = \"TLSv1.3_2025\" # FIX: enforces TLS 1.3 + post-quantum KEX\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use a **custom SSL certificate** with **SNI** support and set `MinimumProtocolVersion` to `TLSv1.3_2025` so CloudFront refuses TLS 1.2 handshakes and uses the hybrid ML-KEM key exchange. Distributions still using the default CloudFront certificate must be migrated to a custom certificate to enable post-quantum TLS.", + "Url": "https://hub.prowler.com/check/cloudfront_distributions_pqc_tls_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfront_distributions_using_deprecated_ssl_protocols" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py new file mode 100644 index 0000000000..6c472d43c7 --- /dev/null +++ b/prowler/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled.py @@ -0,0 +1,56 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.cloudfront.cloudfront_client import ( + cloudfront_client, +) + +PQC_CLOUDFRONT_POLICIES_DEFAULT = [ + "TLSv1.3_2025", +] + + +class cloudfront_distributions_pqc_tls_enabled(Check): + """Verify that every CloudFront distribution enforces TLS 1.3 with post-quantum key exchange. + + Quantum-safe key exchanges (``X25519MLKEM768``, ``SecP256r1MLKEM768``) are + only available on TLS 1.3 viewer connections. A distribution PASSES when + its ``MinimumProtocolVersion`` belongs to the configured allowlist of + TLS 1.3-only policies. Distributions that rely on the default CloudFront + certificate are pinned to the legacy ``TLSv1`` policy and therefore FAIL. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the CloudFront post-quantum TLS policy check. + + Returns: + A list of reports containing each CloudFront distribution's + post-quantum TLS compliance status. + """ + findings = [] + pqc_policies = cloudfront_client.audit_config.get( + "cloudfront_pqc_min_protocol_versions", PQC_CLOUDFRONT_POLICIES_DEFAULT + ) + for distribution in cloudfront_client.distributions.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=distribution) + policy = distribution.minimum_protocol_version or "" + if distribution.default_certificate: + report.status = "FAIL" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses the default " + "CloudFront certificate, which pins the security policy to " + "TLSv1 and cannot enable post-quantum TLS." + ) + elif distribution.minimum_protocol_version in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses post-quantum " + f"TLS policy {policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"CloudFront Distribution {distribution.id} uses TLS policy " + f"{policy}, which is not in the post-quantum allowlist." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_service.py b/prowler/providers/aws/services/cloudfront/cloudfront_service.py index 0826c9adfd..660b483c4c 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_service.py +++ b/prowler/providers/aws/services/cloudfront/cloudfront_service.py @@ -48,6 +48,9 @@ class CloudFront(AWSService): "SSLSupportMethod", "static-ip" ) ) + minimum_protocol_version = item["ViewerCertificate"].get( + "MinimumProtocolVersion", "" + ) origins = [] for origin in item.get("Origins", {}).get("Items", []): origins.append( @@ -79,6 +82,7 @@ class CloudFront(AWSService): ssl_support_method=ssl_support_method, default_certificate=default_certificate, certificate=certificate, + minimum_protocol_version=minimum_protocol_version, ) self.distributions[distribution_id] = distribution @@ -268,3 +272,4 @@ class Distribution(BaseModel): origin_failover: Optional[bool] = None ssl_support_method: Optional[SSLSupportMethod] = None certificate: Optional[str] = None + minimum_protocol_version: str = "" diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json index 20ed5f0adb..9070c7b0c7 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.metadata.json @@ -17,9 +17,8 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v130/monitoring/cis-v130-4-11", "https://support.icompaas.com/support/solutions/articles/62000084031-ensure-a-log-metric-filter-and-alarm-exist-for-changes-to-network-access-control-lists-nacl-", - "https://trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", + "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html", "https://support.icompaas.com/support/solutions/articles/62000233134-4-11-ensure-network-access-control-list-nacl-changes-are-monitored-manual-" ], "Remediation": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py index 20d68a0121..68f45a8d27 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_acls_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateNetworkAcl.+\$\.eventName\s*=\s*.?CreateNetworkAclEntry.+\$\.eventName\s*=\s*.?DeleteNetworkAcl.+\$\.eventName\s*=\s*.?DeleteNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclAssociation.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateNetworkAcl", + "CreateNetworkAclEntry", + "DeleteNetworkAcl", + "DeleteNetworkAclEntry", + "ReplaceNetworkAclEntry", + "ReplaceNetworkAclAssociation", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py index 5f6eda3973..f7bf8e1d22 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_gateways_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateCustomerGateway.+\$\.eventName\s*=\s*.?DeleteCustomerGateway.+\$\.eventName\s*=\s*.?AttachInternetGateway.+\$\.eventName\s*=\s*.?CreateInternetGateway.+\$\.eventName\s*=\s*.?DeleteInternetGateway.+\$\.eventName\s*=\s*.?DetachInternetGateway.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateCustomerGateway", + "DeleteCustomerGateway", + "AttachInternetGateway", + "CreateInternetGateway", + "DeleteInternetGateway", + "DetachInternetGateway", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json index 82490a63da..89949cfbd6 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.metadata.json @@ -37,5 +37,5 @@ ], "DependsOn": [], "RelatedTo": [], - "Notes": "" + "Notes": "Logging and Monitoring" } diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py index f8fcc8eacb..460765cb2f 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,18 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_network_route_tables_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?ec2.amazonaws.com.+\$\.eventName\s*=\s*.?CreateRoute.+\$\.eventName\s*=\s*.?CreateRouteTable.+\$\.eventName\s*=\s*.?ReplaceRoute.+\$\.eventName\s*=\s*.?ReplaceRouteTableAssociation.+\$\.eventName\s*=\s*.?DeleteRouteTable.+\$\.eventName\s*=\s*.?DeleteRoute.+\$\.eventName\s*=\s*.?DisassociateRouteTable.?" + pattern = build_metric_filter_pattern( + event_source="ec2.amazonaws.com", + event_names=[ + "CreateRoute", + "CreateRouteTable", + "ReplaceRoute", + "ReplaceRouteTableAssociation", + "DeleteRouteTable", + "DeleteRoute", + "DisassociateRouteTable", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py index d7606647c4..be4fb0859d 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,21 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_changes_to_vpcs_alarm_configured(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateVpc.+\$\.eventName\s*=\s*.?DeleteVpc.+\$\.eventName\s*=\s*.?ModifyVpcAttribute.+\$\.eventName\s*=\s*.?AcceptVpcPeeringConnection.+\$\.eventName\s*=\s*.?CreateVpcPeeringConnection.+\$\.eventName\s*=\s*.?DeleteVpcPeeringConnection.+\$\.eventName\s*=\s*.?RejectVpcPeeringConnection.+\$\.eventName\s*=\s*.?AttachClassicLinkVpc.+\$\.eventName\s*=\s*.?DetachClassicLinkVpc.+\$\.eventName\s*=\s*.?DisableVpcClassicLink.+\$\.eventName\s*=\s*.?EnableVpcClassicLink.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateVpc", + "DeleteVpc", + "ModifyVpcAttribute", + "AcceptVpcPeeringConnection", + "CreateVpcPeeringConnection", + "DeleteVpcPeeringConnection", + "RejectVpcPeeringConnection", + "AttachClassicLinkVpc", + "DetachClassicLinkVpc", + "DisableVpcClassicLink", + "EnableVpcClassicLink", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py index 11bf08d99d..49bf9a03a3 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change Check ): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?config.amazonaws.com.+\$\.eventName\s*=\s*.?StopConfigurationRecorder.+\$\.eventName\s*=\s*.?DeleteDeliveryChannel.+\$\.eventName\s*=\s*.?PutDeliveryChannel.+\$\.eventName\s*=\s*.?PutConfigurationRecorder.?" + pattern = build_metric_filter_pattern( + event_source="config.amazonaws.com", + event_names=[ + "StopConfigurationRecorder", + "DeleteDeliveryChannel", + "PutDeliveryChannel", + "PutConfigurationRecorder", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py index eb272ecfb1..e9567315f4 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change Check ): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?CreateTrail.+\$\.eventName\s*=\s*.?UpdateTrail.+\$\.eventName\s*=\s*.?DeleteTrail.+\$\.eventName\s*=\s*.?StartLogging.+\$\.eventName\s*=\s*.?StopLogging.?" + pattern = build_metric_filter_pattern( + event_names=[ + "CreateTrail", + "UpdateTrail", + "DeleteTrail", + "StartLogging", + "StopLogging", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py index c217cbbaf6..1b2e5173bb 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_authentication_failures(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.errorMessage\s*=\s*.?Failed authentication.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", "=", "Failed authentication")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py index af7ec82119..9976a885bb 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,32 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_aws_organizations_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?organizations\.amazonaws\.com.+\$\.eventName\s*=\s*.?AcceptHandshake.+\$\.eventName\s*=\s*.?AttachPolicy.+\$\.eventName\s*=\s*.?CancelHandshake.+\$\.eventName\s*=\s*.?CreateAccount.+\$\.eventName\s*=\s*.?CreateOrganization.+\$\.eventName\s*=\s*.?CreateOrganizationalUnit.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeclineHandshake.+\$\.eventName\s*=\s*.?DeleteOrganization.+\$\.eventName\s*=\s*.?DeleteOrganizationalUnit.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?EnableAllFeatures.+\$\.eventName\s*=\s*.?EnablePolicyType.+\$\.eventName\s*=\s*.?InviteAccountToOrganization.+\$\.eventName\s*=\s*.?LeaveOrganization.+\$\.eventName\s*=\s*.?DetachPolicy.+\$\.eventName\s*=\s*.?DisablePolicyType.+\$\.eventName\s*=\s*.?MoveAccount.+\$\.eventName\s*=\s*.?RemoveAccountFromOrganization.+\$\.eventName\s*=\s*.?UpdateOrganizationalUnit.+\$\.eventName\s*=\s*.?UpdatePolicy.?" + pattern = build_metric_filter_pattern( + event_source="organizations.amazonaws.com", + event_names=[ + "AcceptHandshake", + "AttachPolicy", + "CancelHandshake", + "CreateAccount", + "CreateOrganization", + "CreateOrganizationalUnit", + "CreatePolicy", + "DeclineHandshake", + "DeleteOrganization", + "DeleteOrganizationalUnit", + "DeletePolicy", + "EnableAllFeatures", + "EnablePolicyType", + "InviteAccountToOrganization", + "LeaveOrganization", + "DetachPolicy", + "DisablePolicyType", + "MoveAccount", + "RemoveAccountFromOrganization", + "UpdateOrganizationalUnit", + "UpdatePolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py index 4cfed985f7..20d1d62a5a 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?kms.amazonaws.com.+\$\.eventName\s*=\s*.?DisableKey.+\$\.eventName\s*=\s*.?ScheduleKeyDeletion.?" + pattern = build_metric_filter_pattern( + event_source="kms.amazonaws.com", + event_names=["DisableKey", "ScheduleKeyDeletion"], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json index 2d77c42704..6292de6071 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.metadata.json @@ -17,8 +17,7 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes", - "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v5.0.0_L1.audit:8101350d6907e07863ac6748689b3e12" + "https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes" ], "Remediation": { "Code": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py index 45d09b3528..d5fe1ef994 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,20 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check): def execute(self): - pattern = r"\$\.eventSource\s*=\s*.?s3.amazonaws.com.+\$\.eventName\s*=\s*.?PutBucketAcl.+\$\.eventName\s*=\s*.?PutBucketPolicy.+\$\.eventName\s*=\s*.?PutBucketCors.+\$\.eventName\s*=\s*.?PutBucketLifecycle.+\$\.eventName\s*=\s*.?PutBucketReplication.+\$\.eventName\s*=\s*.?DeleteBucketPolicy.+\$\.eventName\s*=\s*.?DeleteBucketCors.+\$\.eventName\s*=\s*.?DeleteBucketLifecycle.+\$\.eventName\s*=\s*.?DeleteBucketReplication.?" + pattern = build_metric_filter_pattern( + event_source="s3.amazonaws.com", + event_names=[ + "PutBucketAcl", + "PutBucketPolicy", + "PutBucketCors", + "PutBucketLifecycle", + "PutBucketReplication", + "DeleteBucketPolicy", + "DeleteBucketCors", + "DeleteBucketLifecycle", + "DeleteBucketReplication", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json index 75cba1ee62..b1c36be2d8 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.metadata.json @@ -17,7 +17,6 @@ "RelatedUrl": "", "AdditionalURLs": [ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", - "https://www.clouddefense.ai/compliance-rules/cis-v140/monitoring/cis-v140-4-4", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-iam-policy-change" ], "Remediation": { diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py index f5efd04dde..4347a9b3aa 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,26 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_policy_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?DeleteGroupPolicy.+\$\.eventName\s*=\s*.?DeleteRolePolicy.+\$\.eventName\s*=\s*.?DeleteUserPolicy.+\$\.eventName\s*=\s*.?PutGroupPolicy.+\$\.eventName\s*=\s*.?PutRolePolicy.+\$\.eventName\s*=\s*.?PutUserPolicy.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?CreatePolicyVersion.+\$\.eventName\s*=\s*.?DeletePolicyVersion.+\$\.eventName\s*=\s*.?AttachRolePolicy.+\$\.eventName\s*=\s*.?DetachRolePolicy.+\$\.eventName\s*=\s*.?AttachUserPolicy.+\$\.eventName\s*=\s*.?DetachUserPolicy.+\$\.eventName\s*=\s*.?AttachGroupPolicy.+\$\.eventName\s*=\s*.?DetachGroupPolicy.?" + pattern = build_metric_filter_pattern( + event_names=[ + "DeleteGroupPolicy", + "DeleteRolePolicy", + "DeleteUserPolicy", + "PutGroupPolicy", + "PutRolePolicy", + "PutUserPolicy", + "CreatePolicy", + "DeletePolicy", + "CreatePolicyVersion", + "DeletePolicyVersion", + "AttachRolePolicy", + "DetachRolePolicy", + "AttachUserPolicy", + "DetachUserPolicy", + "AttachGroupPolicy", + "DetachGroupPolicy", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py index a7972e420c..3557632904 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_security_group_changes(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?AuthorizeSecurityGroupIngress.+\$\.eventName\s*=\s*.?AuthorizeSecurityGroupEgress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupIngress.+\$\.eventName\s*=\s*.?RevokeSecurityGroupEgress.+\$\.eventName\s*=\s*.?CreateSecurityGroup.+\$\.eventName\s*=\s*.?DeleteSecurityGroup.?" + pattern = build_metric_filter_pattern( + event_names=[ + "AuthorizeSecurityGroupIngress", + "AuthorizeSecurityGroupEgress", + "RevokeSecurityGroupIngress", + "RevokeSecurityGroupEgress", + "CreateSecurityGroup", + "DeleteSecurityGroup", + ], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json index 20058c1ed8..76ffd832ff 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.metadata.json @@ -21,7 +21,6 @@ "https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html", "https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/console-sign-in-without-mfa.html", "https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v3.0.0_L1.audit:1957056ee174cc38502d5f5f1864333b", - "https://www.clouddefense.ai/compliance-rules/gdpr/data-protection/log-metric-filter-console-login-mfa", "https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-no-mfa", "https://support.icompaas.com/support/solutions/articles/62000083605-ensure-a-log-metric-filter-and-alarm-exist-for-management-console-sign-in-without-mfa" ], diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py index 8437600646..07475a6185 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa.py @@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import ( cloudwatch_client, ) from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, check_cloudwatch_log_metric_filter, ) from prowler.providers.aws.services.cloudwatch.logs_client import logs_client @@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client class cloudwatch_log_metric_filter_sign_in_without_mfa(Check): def execute(self): - pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.additionalEventData\.MFAUsed\s*!=\s*.?Yes.?" + pattern = build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("additionalEventData.MFAUsed", "!=", "Yes")], + ) findings = [] report = check_cloudwatch_log_metric_filter( diff --git a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py index 84d70b4083..e5d104b840 100644 --- a/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py +++ b/prowler/providers/aws/services/cloudwatch/lib/metric_filters.py @@ -3,6 +3,45 @@ import re from prowler.lib.check.models import Check_Report_AWS +def build_metric_filter_pattern( + *, + event_names: list[str] | None = None, + event_source: str | None = None, + extra_clauses: list[tuple[str, str, str]] | None = None, +) -> str: + """Build a regex pattern to match a CloudWatch Logs filterPattern string. + + All clauses must be present for the pattern to match, regardless of the + order in which AWS stores them. Event names are matched exactly, so a + short name like ``CreateRoute`` will not be satisfied by a longer one + like ``CreateRouteTable``. + + Pass the result directly to ``check_cloudwatch_log_metric_filter``. + + Args: + event_names: AWS API action names to require (``$.eventName``). + event_source: optional service principal to require (``$.eventSource``), + e.g. ``"ec2.amazonaws.com"``. + extra_clauses: additional conditions as ``(field, operator, value)`` + tuples, where ``operator`` is ``"="`` or ``"!="``. Example: + ``("additionalEventData.MFAUsed", "!=", "Yes")``. + + Returns: + A regex string for use with ``re.search(..., flags=re.DOTALL)``. + """ + parts: list[str] = [] + if event_source is not None: + parts.append(rf"(?=.*\$\.eventSource\s*=\s*.?{re.escape(event_source)})") + for name in event_names or []: + parts.append(rf"(?=.*\$\.eventName\s*=\s*.?{re.escape(name)}\b)") + for field, operator, value in extra_clauses or []: + if operator not in ("=", "!="): + raise ValueError(f"unsupported operator {operator!r}; expected '=' or '!='") + op = r"\s*!=\s*" if operator == "!=" else r"\s*=\s*" + parts.append(rf"(?=.*\$\.{re.escape(field)}{op}.?{re.escape(value)})") + return "".join(parts) + + def check_cloudwatch_log_metric_filter( metric_filter_pattern: str, trails: list, diff --git a/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py b/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py index 0c745fc658..bc9f569f1b 100644 --- a/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py +++ b/prowler/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private.py @@ -7,6 +7,8 @@ from prowler.providers.aws.services.codepipeline.codepipeline_client import ( codepipeline_client, ) +HTTP_TIMEOUT = 30 + class codepipeline_project_repo_private(Check): """Checks if AWS CodePipeline source repositories are configured as private. @@ -87,9 +89,8 @@ class codepipeline_project_repo_private(Check): repo_url = repo_url[:-4] try: - context = ssl._create_unverified_context() req = urllib.request.Request(repo_url, method="HEAD") - response = urllib.request.urlopen(req, context=context) + response = urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) return not response.geturl().endswith("sign_in") - except (urllib.error.HTTPError, urllib.error.URLError): + except (urllib.error.URLError, TimeoutError, ssl.SSLError): return False diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json new file mode 100644 index 0000000000..cbbd7a66dd --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "config_delegated_admin_and_org_aggregator_all_regions", + "CheckTitle": "AWS Config has a delegated administrator and an organization aggregator covering all AWS regions", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "config", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsConfigConfigurationAggregator", + "ResourceGroup": "governance", + "Description": "**AWS Config** has a delegated administrator registered via AWS Organizations and at least one Configuration Aggregator with an OrganizationAggregationSource that covers all AWS regions, ensuring centralized org-wide configuration visibility.", + "Risk": "Without an org-wide **AWS Config** aggregator and a delegated administrator, configuration data is fragmented across accounts and regions, **compliance reporting** is incomplete, and **drift detection** is delayed. Adversaries or misconfigurations can persist in unmonitored accounts, eroding **audit readiness** and **regulatory posture**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/config/latest/developerguide/aggregate-data.html", + "https://docs.aws.amazon.com/config/latest/developerguide/set-up-aggregator-cli.html", + "https://docs.aws.amazon.com/organizations/latest/userguide/services-that-can-integrate-config.html" + ], + "Remediation": { + "Code": { + "CLI": "aws organizations register-delegated-administrator --account-id --service-principal config.amazonaws.com && aws configservice put-configuration-aggregator --configuration-aggregator-name org-aggregator --organization-aggregation-source RoleArn=,AllAwsRegions=true", + "NativeIaC": "", + "Other": "1. From the AWS Organizations management account, register the delegated administrator for config.amazonaws.com\n2. In the delegated admin account, open AWS Config\n3. Create a Configuration Aggregator and select Add my organization as the source\n4. Enable Include all AWS Regions\n5. Confirm an IAM role with AWSConfigRoleForOrganizations is attached\n6. Verify the aggregator status reaches SUCCEEDED for all member accounts", + "Terraform": "" + }, + "Recommendation": { + "Text": "Register a **delegated administrator** for AWS Config via AWS Organizations and create at least one **Configuration Aggregator** with an OrganizationAggregationSource that covers **all AWS regions**. This centralizes configuration data across the organization for unified compliance and audit reporting.", + "Url": "https://hub.prowler.com/check/config_delegated_admin_and_org_aggregator_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "config_recorder_all_regions_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py new file mode 100644 index 0000000000..f56ea191f8 --- /dev/null +++ b/prowler/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions.py @@ -0,0 +1,115 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.config.config_client import config_client +from prowler.providers.aws.services.config.config_service import Aggregator + + +class config_delegated_admin_and_org_aggregator_all_regions(Check): + """Ensure AWS Config has a delegated admin and an org aggregator covering all regions. + + This check verifies that: + 1. A delegated administrator is registered for the config.amazonaws.com + service principal via AWS Organizations. + 2. At least one AWS Config Configuration Aggregator exists with an + OrganizationAggregationSource that covers all AWS regions + (AllAwsRegions=true). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. One finding per + aggregator-region, or a single synthetic FAIL when no aggregators + exist in any region. + """ + findings = [] + + has_delegated_admin = ( + bool(config_client.delegated_administrators) + and not config_client.delegated_administrators_lookup_failed + ) + delegated_admin_unknown = config_client.delegated_administrators_lookup_failed + + # No aggregators in any region: emit one synthetic FAIL anchored to the + # audited account in the default region. + if not config_client.aggregators: + synthetic = Aggregator( + name="unknown", + arn=config_client.get_unknown_arn( + region=config_client.region, + resource_type="config-aggregator", + ), + region=config_client.region, + all_aws_regions=False, + aws_regions=None, + organization_aggregation_source_present=False, + ) + report = Check_Report_AWS(metadata=self.metadata(), resource=synthetic) + if delegated_admin_unknown: + delegated_state = ( + "delegated administrator status could not be determined" + ) + elif has_delegated_admin: + delegated_state = "delegated administrator configured" + else: + delegated_state = ( + "no delegated administrator registered for config.amazonaws.com" + ) + report.status = "FAIL" + report.status_extended = ( + f"AWS Config has no Organization Aggregator configured in any " + f"region ({delegated_state})." + ) + findings.append(report) + return findings + + for region, aggregators_in_region in config_client.aggregators.items(): + for aggregator in aggregators_in_region: + report = Check_Report_AWS(metadata=self.metadata(), resource=aggregator) + + org_aware = aggregator.organization_aggregation_source_present + covers_all = aggregator.all_aws_regions + + issues = [] + if delegated_admin_unknown: + issues.append( + "delegated administrator status for config.amazonaws.com " + "could not be determined" + ) + elif not has_delegated_admin: + issues.append( + "no delegated administrator registered for config.amazonaws.com" + ) + if not org_aware: + issues.append( + f"aggregator {aggregator.name} is not an organization aggregator" + ) + elif not covers_all: + issues.append( + f"aggregator {aggregator.name} does not cover all AWS regions" + ) + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} has issues: {', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"AWS Config aggregator {aggregator.name} in region " + f"{region} is an organization aggregator covering all " + f"AWS regions with delegated admin configured." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + config_client.audit_config.get("mute_non_default_regions", False) + and region != config_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/config/config_service.py b/prowler/providers/aws/services/config/config_service.py index 443cee2233..85ab22fd8e 100644 --- a/prowler/providers/aws/services/config/config_service.py +++ b/prowler/providers/aws/services/config/config_service.py @@ -1,5 +1,6 @@ from typing import Optional +from botocore.client import ClientError from pydantic.v1 import BaseModel from prowler.lib.logger import logger @@ -12,10 +13,16 @@ class Config(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.recorders = {} + self.aggregators: dict[str, list] = {} + self.delegated_administrators: list = [] + self.delegated_administrators_lookup_failed: bool = False self.__threading_call__(self.describe_configuration_recorders) self.__threading_call__( self._describe_configuration_recorder_status, self.recorders.values() ) + self.__threading_call__(self._describe_configuration_aggregators) + # Organizations API is not regional; single call. + self._list_config_delegated_administrators() def _get_recorder_arn_template(self, region): return f"arn:{self.audited_partition}:config:{region}:{self.audited_account}:recorder" @@ -73,6 +80,108 @@ class Config(AWSService): f"{recorder.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _describe_configuration_aggregators(self, regional_client): + """Describe AWS Config configuration aggregators per region. + + An aggregator counts as organization-aware when its + OrganizationAggregationSource key is present in the response. + """ + logger.info("Config - Describing Configuration Aggregators...") + try: + paginator = regional_client.get_paginator( + "describe_configuration_aggregators" + ) + region_aggregators: list = [] + for page in paginator.paginate(): + for aggregator in page.get("ConfigurationAggregators", []): + name = aggregator.get("ConfigurationAggregatorName", "") + arn = aggregator.get("ConfigurationAggregatorArn", "") + org_source = aggregator.get("OrganizationAggregationSource") + org_aware = org_source is not None + all_aws_regions = False + aws_regions: Optional[list] = None + if org_aware: + all_aws_regions = org_source.get("AllAwsRegions", False) + aws_regions = org_source.get("AwsRegions") + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + region_aggregators.append( + Aggregator( + name=name, + arn=arn, + region=regional_client.region, + all_aws_regions=all_aws_regions, + aws_regions=aws_regions, + organization_aggregation_source_present=org_aware, + ) + ) + if region_aggregators: + self.aggregators[regional_client.region] = region_aggregators + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "AccessDenied", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _list_config_delegated_administrators(self): + """List delegated administrators for the AWS Config service principal. + + Uses the Organizations API directly (not regional). Sets + delegated_administrators_lookup_failed to True on AccessDenied so callers + can surface the unknown delegated-admin state in findings. + """ + logger.info( + "Config - Listing delegated administrators for config.amazonaws.com..." + ) + try: + org_client = self.session.client("organizations") + paginator = org_client.get_paginator("list_delegated_administrators") + for page in paginator.paginate(ServicePrincipal="config.amazonaws.com"): + for admin in page.get("DelegatedAdministrators", []): + self.delegated_administrators.append( + ConfigDelegatedAdministrator( + id=admin.get("Id", ""), + arn=admin.get("Arn", ""), + name=admin.get("Name", ""), + email=admin.get("Email", ""), + status=admin.get("Status", ""), + joined_method=admin.get("JoinedMethod", ""), + ) + ) + except ClientError as error: + error_code = error.response["Error"]["Code"] + if error_code in ( + "AccessDeniedException", + "AccessDenied", + "AWSOrganizationsNotInUseException", + ): + self.delegated_administrators_lookup_failed = True + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.delegated_administrators_lookup_failed = True + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + class Recorder(BaseModel): name: str @@ -80,3 +189,25 @@ class Recorder(BaseModel): recording: Optional[bool] last_status: Optional[str] region: str + + +class Aggregator(BaseModel): + """Represents an AWS Config Configuration Aggregator.""" + + name: str + arn: str + region: str + all_aws_regions: bool = False + aws_regions: Optional[list] = None + organization_aggregation_source_present: bool = False + + +class ConfigDelegatedAdministrator(BaseModel): + """Represents a delegated administrator registered for config.amazonaws.com.""" + + id: str + arn: str + name: str + email: str + status: str + joined_method: str diff --git a/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json b/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json index 79b02882d7..4c7962242e 100644 --- a/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json +++ b/prowler/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation.metadata.json @@ -37,7 +37,8 @@ } }, "Categories": [ - "identity-access" + "identity-access", + "privilege-escalation" ], "DependsOn": [], "RelatedTo": [], diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index d6cb150178..fa01550b11 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -103,6 +103,9 @@ class IAM(AWSService): self._get_user_temporary_credentials_usage() self.organization_features = [] self._list_organizations_features() + # ListRoles does not echo PermissionsBoundary; backfill via GetRole. + if self.roles: + self.__threading_call__(self._get_role_permissions_boundary, self.roles) # List missing tags self.__threading_call__(self._list_tags, self.users) self.__threading_call__(self._list_tags, self.roles) @@ -133,6 +136,7 @@ class IAM(AWSService): arn=role["Arn"], assume_role_policy=role["AssumeRolePolicyDocument"], is_service_role=is_service_role(role), + permissions_boundary=role.get("PermissionsBoundary"), ) ) except ClientError as error: @@ -460,6 +464,34 @@ class IAM(AWSService): f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_role_permissions_boundary(self, role): + """Backfill ``role.permissions_boundary`` via ``GetRole``. + + ``ListRoles`` does not return ``PermissionsBoundary`` in practice, so + the value is fetched per role and stored on the ``Role`` model. + + Args: + role: The ``Role`` instance to enrich. + """ + try: + response = self.client.get_role(RoleName=role.name) + role.permissions_boundary = response.get("Role", {}).get( + "PermissionsBoundary" + ) + except ClientError as error: + if error.response["Error"]["Code"] == "NoSuchEntity": + logger.warning( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _list_attached_role_policies(self): logger.info("IAM - List Attached Role Policies...") try: @@ -1139,6 +1171,7 @@ class Role(BaseModel): is_service_role: bool attached_policies: list[dict] = [] inline_policies: list[str] = [] + permissions_boundary: Optional[dict] = None tags: Optional[list] diff --git a/prowler/providers/aws/services/rolesanywhere/__init__.py b/prowler/providers/aws/services/rolesanywhere/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py new file mode 100644 index 0000000000..254f0a5dbf --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_client.py @@ -0,0 +1,6 @@ +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + RolesAnywhere, +) +from prowler.providers.common.provider import Provider + +rolesanywhere_client = RolesAnywhere(Provider.get_global_provider()) diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py new file mode 100644 index 0000000000..46dca281d6 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_service.py @@ -0,0 +1,64 @@ +from typing import Dict, List + +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered +from prowler.providers.aws.lib.service.service import AWSService + + +class RolesAnywhere(AWSService): + def __init__(self, provider): + super().__init__(__class__.__name__, provider) + self.trust_anchors = {} + self.__threading_call__(self._list_trust_anchors) + + def _list_trust_anchors(self, regional_client): + logger.info("RolesAnywhere - Listing Trust Anchors...") + try: + paginator = regional_client.get_paginator("list_trust_anchors") + for page in paginator.paginate(): + for ta in page.get("trustAnchors", []): + arn = ta.get("trustAnchorArn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + source = ta.get("source", {}) or {} + source_data = source.get("sourceData", {}) or {} + tags = [] + try: + tags = regional_client.list_tags_for_resource( + resourceArn=arn + ).get("tags", []) + except Exception as error: + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.trust_anchors[arn] = TrustAnchor( + arn=arn, + id=ta.get("trustAnchorId", ""), + name=ta.get("name", ""), + region=regional_client.region, + enabled=ta.get("enabled", False), + source_type=source.get("sourceType", ""), + acm_pca_arn=source_data.get("acmPcaArn", ""), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class TrustAnchor(BaseModel): + arn: str + id: str + name: str + region: str + enabled: bool = False + source_type: str = "" + acm_pca_arn: str = "" + tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/__init__.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json new file mode 100644 index 0000000000..7d919f83a7 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "rolesanywhere_trust_anchor_pqc_pki", + "CheckTitle": "IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "rolesanywhere", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsRolesAnywhereTrustAnchor", + "ResourceGroup": "security", + "Description": "**IAM Roles Anywhere trust anchors** are assessed for use of a **post-quantum digital signature algorithm** (ML-DSA). A trust anchor backed by an AWS Private CA whose `KeyAlgorithm` is RSA or ECC produces signatures vulnerable to forgery by a future quantum attacker, allowing an unintended actor to issue certificates and obtain unauthorized AWS access.", + "Risk": "Trust anchors are the root of trust for workloads authenticating to AWS via X.509 certificates. If the signing CA uses RSA or ECC, an attacker with quantum capability could forge end-entity certificates and impersonate workloads. Migrating trust anchors to **ML-DSA-backed PKI** (NIST FIPS 204) protects this control plane in the post-quantum era.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/rolesanywhere/latest/userguide/introduction.html", + "https://aws.amazon.com/about-aws/whats-new/2026/03/iam-roles-anywhere-post-quantum-digital-certificates/", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/pubs/fips/204/final" + ], + "Remediation": { + "Code": { + "CLI": "aws rolesanywhere create-trust-anchor --name pqc-trust --source 'sourceType=AWS_ACM_PCA,sourceData={acmPcaArn=}' --enabled", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::RolesAnywhere::TrustAnchor\n Properties:\n Name: pqc-trust\n Enabled: true\n Source:\n SourceType: AWS_ACM_PCA\n SourceData:\n AcmPcaArn: # FIX: PCA must use ML_DSA key algorithm\n```", + "Other": "1. Create a new AWS Private CA with a post-quantum KeyAlgorithm (ML_DSA_44/65/87)\n2. Create a new Roles Anywhere trust anchor with sourceType=AWS_ACM_PCA pointing to the new CA\n3. Rotate end-entity certificates issued from the new CA\n4. Delete the legacy trust anchor once workloads have rotated", + "Terraform": "```hcl\nresource \"aws_rolesanywhere_trust_anchor\" \"\" {\n name = \"pqc-trust\"\n enabled = true\n source {\n source_type = \"AWS_ACM_PCA\"\n source_data {\n acm_pca_arn = \"\" # FIX: PCA must use ML_DSA key algorithm\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Back IAM Roles Anywhere trust anchors with an **AWS Private CA that uses an ML-DSA key algorithm**. For trust anchors backed by an external certificate bundle, ensure the certificates were issued by an ML-DSA CA and re-verify periodically as the cryptographic landscape evolves.", + "Url": "https://hub.prowler.com/check/rolesanywhere_trust_anchor_pqc_pki" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [ + "acmpca_certificate_authority_pqc_key_algorithm" + ], + "RelatedTo": [], + "Notes": "Trust anchors backed by an external CERTIFICATE_BUNDLE cannot be evaluated automatically by this check and are reported as FAIL with guidance to migrate to an AWS Private CA using an ML-DSA key algorithm." +} diff --git a/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py new file mode 100644 index 0000000000..495ed4e9c7 --- /dev/null +++ b/prowler/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki.py @@ -0,0 +1,79 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_client import ( + rolesanywhere_client, +) + +PQC_PCA_KEY_ALGORITHMS_DEFAULT = [ + "ML_DSA_44", + "ML_DSA_65", + "ML_DSA_87", +] + + +class rolesanywhere_trust_anchor_pqc_pki(Check): + """Verify that IAM Roles Anywhere trust anchors are backed by a post-quantum PKI. + + For trust anchors whose source is ``AWS_ACM_PCA``, the linked Private CA's + ``KeyAlgorithm`` is checked against the configured ML-DSA allowlist. + Trust anchors backed by an external ``CERTIFICATE_BUNDLE`` are reported as + FAIL because their certificate signature algorithm cannot be inspected + from the IAM Roles Anywhere API alone. + """ + + def execute(self) -> list[Check_Report_AWS]: + findings = [] + pqc_algorithms = rolesanywhere_client.audit_config.get( + "rolesanywhere_pqc_pca_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT + ) + for trust_anchor in rolesanywhere_client.trust_anchors.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=trust_anchor) + if trust_anchor.source_type == "AWS_ACM_PCA": + linked_ca = acmpca_client.certificate_authorities.get( + trust_anchor.acm_pca_arn + ) + if linked_ca and linked_ca.status != "ACTIVE": + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id}, which is in " + f"{linked_ca.status or ''} status and cannot be " + "used as an active post-quantum PKI trust root." + ) + elif linked_ca and linked_ca.key_algorithm in pqc_algorithms: + report.status = "PASS" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id} using post-quantum " + f"key algorithm {linked_ca.key_algorithm}." + ) + elif linked_ca: + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {linked_ca.id} using key algorithm " + f"{linked_ca.key_algorithm or ''}, which is not " + "post-quantum (ML-DSA)." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} is " + f"backed by Private CA {trust_anchor.acm_pca_arn}, which " + "could not be inspected (cross-account or missing " + "acm-pca permissions). Verify the CA uses an ML-DSA key " + "algorithm." + ) + else: + source = trust_anchor.source_type or "" + report.status = "FAIL" + report.status_extended = ( + f"IAM Roles Anywhere trust anchor {trust_anchor.name} uses " + f"source type {source}; the certificate signature algorithm " + "cannot be inspected automatically. Migrate to an AWS Private " + "CA using an ML-DSA key algorithm to enable post-quantum " + "evaluation." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py index caf3629816..25703b979f 100644 --- a/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py +++ b/prowler/providers/aws/services/route53/route53_dangling_ip_subdomain_takeover/route53_dangling_ip_subdomain_takeover.py @@ -1,10 +1,9 @@ import re from ipaddress import ip_address -import awsipranges - from prowler.lib.check.models import Check, Check_Report_AWS from prowler.lib.utils.utils import validate_ip_address +from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks from prowler.providers.aws.services.ec2.ec2_client import ec2_client from prowler.providers.aws.services.route53.route53_client import route53_client from prowler.providers.aws.services.s3.s3_client import s3_client @@ -21,6 +20,10 @@ class route53_dangling_ip_subdomain_takeover(Check): def execute(self) -> Check_Report_AWS: findings = [] + # AWS public IP prefixes are fetched lazily, at most once per run, only + # when a dangling-candidate public IP is found. + aws_ip_networks = None + # When --region is used, Route53 service gathers EIPs from all regions # to avoid false positives. Otherwise, use ec2_client data directly. if route53_client.all_account_elastic_ips: @@ -42,6 +45,7 @@ class route53_dangling_ip_subdomain_takeover(Check): if record_set.type == "A" and not record_set.is_alias: for record in record_set.records: if validate_ip_address(record): + record_ip = ip_address(record) report = Check_Report_AWS( metadata=self.metadata(), resource=record_set ) @@ -53,14 +57,12 @@ class route53_dangling_ip_subdomain_takeover(Check): report.status = "PASS" report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is not a dangling IP." # If Public IP check if it is in the AWS Account - if ( - not ip_address(record).is_private - and record not in public_ips - ): + if not record_ip.is_private and record not in public_ips: report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} does not belong to AWS and it is not a dangling IP." # Check if potential dangling IP is within AWS Ranges - aws_ip_ranges = awsipranges.get_ranges() - if aws_ip_ranges.get(record): + if aws_ip_networks is None: + aws_ip_networks = get_public_ip_networks() + if any(record_ip in network for network in aws_ip_networks): report.status = "FAIL" report.status_extended = f"Route53 record {record} (name: {record_set.name}) in Hosted Zone {hosted_zone.name} is a dangling IP which can lead to a subdomain takeover attack." findings.append(report) diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json new file mode 100644 index 0000000000..0e127a7759 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "aws", + "CheckID": "sagemaker_clarify_exists", + "CheckTitle": "Amazon SageMaker Clarify processing jobs exist in the region", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "sagemaker", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "Other", + "ResourceGroup": "ai_ml", + "Description": "**SageMaker Clarify** provides bias detection and model explainability for ML workloads.\n\nThis check verifies that at least one SageMaker processing job using the AWS-managed Clarify container image exists in each successfully scanned region. The absence of Clarify jobs indicates that responsible-AI controls such as bias detection and explainability are not in place.", + "Risk": "Without **SageMaker Clarify** processing jobs, ML models may be deployed without bias analysis or explainability reports. This can lead to:\n- **Regulatory non-compliance** with AI governance frameworks\n- **Undetected bias** in model predictions affecting protected groups\n- **Lack of accountability** for ML model decisions in production", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/sagemaker/latest/dg/clarify-configure-processing-jobs.html", + "https://docs.aws.amazon.com/sagemaker/latest/dg-ecr-paths/sagemaker-algo-docker-registry-paths.html" + ], + "Remediation": { + "Code": { + "CLI": "aws sagemaker create-processing-job --processing-job-name clarify-bias-check --app-specification ImageUri= --role-arn --processing-resources 'ClusterConfig={InstanceCount=1,InstanceType=ml.m5.xlarge,VolumeSizeInGB=20}'", + "NativeIaC": "", + "Other": "1. Open the AWS Console and go to Amazon SageMaker\n2. Navigate to Processing > Processing jobs\n3. Click Create processing job\n4. Select the SageMaker Clarify container image for your region\n5. Configure input/output paths and the analysis configuration\n6. Click Create processing job", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create SageMaker Clarify processing jobs to evaluate models for bias and explainability before deployment. Integrate Clarify into your ML pipeline to ensure responsible AI practices.", + "Url": "https://hub.prowler.com/check/sagemaker_clarify_exists" + } + }, + "Categories": [ + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Results are generated per scanned region. Regions where `ListProcessingJobs` cannot be queried are omitted from the findings." +} diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py new file mode 100644 index 0000000000..9c559613d3 --- /dev/null +++ b/prowler/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.sagemaker.sagemaker_client import sagemaker_client + + +class sagemaker_clarify_exists(Check): + """Check whether at least one SageMaker Clarify processing job exists per region. + + A region is reported only when ListProcessingJobs succeeded for it; regions + where the API call failed (e.g. AccessDenied, unsupported region) are + skipped at the service layer and produce no finding. + + - PASS: At least one processing job uses the AWS-managed Clarify container + image in the region (one finding per job). + - FAIL: No processing job uses the Clarify container image in the region + (one finding per region). + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the SageMaker Clarify exists check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for region in sorted(sagemaker_client.processing_jobs_scanned_regions): + clarify_jobs = sorted( + ( + job + for job in sagemaker_client.sagemaker_processing_jobs + if job.region == region + and job.image_uri + and "sagemaker-clarify-processing" in job.image_uri + ), + key=lambda job: job.name, + ) + + if clarify_jobs: + for job in clarify_jobs: + report = Check_Report_AWS(metadata=self.metadata(), resource=job) + report.status = "PASS" + report.status_extended = f"SageMaker Clarify processing job {job.name} exists in region {region}." + findings.append(report) + else: + report = Check_Report_AWS(metadata=self.metadata(), resource={}) + report.region = region + report.resource_id = "sagemaker-clarify" + report.resource_arn = f"arn:{sagemaker_client.audited_partition}:sagemaker:{region}:{sagemaker_client.audited_account}:processing-job" + report.status = "FAIL" + report.status_extended = ( + f"No SageMaker Clarify processing jobs found in region {region}." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 5093a4ac02..20ea4c0280 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -15,6 +15,8 @@ class SageMaker(AWSService): self.sagemaker_notebook_instances = [] self.sagemaker_models = [] self.sagemaker_training_jobs = [] + self.sagemaker_processing_jobs = [] + self.processing_jobs_scanned_regions = set() self.sagemaker_domains = [] self.endpoint_configs = {} self.sagemaker_model_registries = [] @@ -24,6 +26,7 @@ class SageMaker(AWSService): self.__threading_call__(self._list_notebook_instances) self.__threading_call__(self._list_models) self.__threading_call__(self._list_training_jobs) + self.__threading_call__(self._list_processing_jobs) self.__threading_call__(self._list_endpoint_configs) self.__threading_call__(self._list_domains) self.__threading_call__(self._list_model_package_groups) @@ -37,6 +40,9 @@ class SageMaker(AWSService): self.__threading_call__( self._describe_training_job, self.sagemaker_training_jobs ) + self.__threading_call__( + self._describe_processing_job, self.sagemaker_processing_jobs + ) self.__threading_call__( self._describe_endpoint_config, list(self.endpoint_configs.values()) ) @@ -51,6 +57,9 @@ class SageMaker(AWSService): self.__threading_call__( self._list_tags_for_resource, self.sagemaker_training_jobs ) + self.__threading_call__( + self._list_tags_for_resource, self.sagemaker_processing_jobs + ) self.__threading_call__( self._list_tags_for_resource, list(self.endpoint_configs.values()) ) @@ -128,6 +137,66 @@ class SageMaker(AWSService): f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_processing_jobs(self, regional_client): + """List SageMaker processing jobs in a region. + + Populates ``self.sagemaker_processing_jobs`` with `ProcessingJob` + entries and adds ``regional_client.region`` to + ``self.processing_jobs_scanned_regions`` once pagination succeeds, so + regions where ``ListProcessingJobs`` fails are skipped by checks that + consume that set. + + Args: + regional_client: Regional SageMaker boto3 client. + """ + logger.info("SageMaker - listing processing jobs...") + try: + list_processing_jobs_paginator = regional_client.get_paginator( + "list_processing_jobs" + ) + for page in list_processing_jobs_paginator.paginate(): + for processing_job in page["ProcessingJobSummaries"]: + if not self.audit_resources or ( + is_resource_filtered( + processing_job["ProcessingJobArn"], self.audit_resources + ) + ): + self.sagemaker_processing_jobs.append( + ProcessingJob( + name=processing_job["ProcessingJobName"], + region=regional_client.region, + arn=processing_job["ProcessingJobArn"], + ) + ) + self.processing_jobs_scanned_regions.add(regional_client.region) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_processing_job(self, processing_job): + """Describe a SageMaker processing job and enrich its image metadata. + + Reads ``AppSpecification.ImageUri`` from ``DescribeProcessingJob`` and + stores it on ``processing_job.image_uri``. Errors are logged and + swallowed so a failure in one job does not abort the scan. + + Args: + processing_job: ProcessingJob model to enrich in-place. + """ + logger.info("SageMaker - describing processing job...") + try: + regional_client = self.regional_clients[processing_job.region] + describe_processing_job = regional_client.describe_processing_job( + ProcessingJobName=processing_job.name + ) + app_spec = describe_processing_job.get("AppSpecification", {}) + processing_job.image_uri = app_spec.get("ImageUri") + except Exception as error: + logger.error( + f"{processing_job.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + def _describe_notebook_instance(self, notebook_instance): logger.info("SageMaker - describing notebook instances...") try: @@ -451,6 +520,25 @@ class TrainingJob(BaseModel): tags: Optional[list] = [] +class ProcessingJob(BaseModel): + """Represents a SageMaker processing job. + + Attributes: + name: Processing job name. + region: AWS region where the job lives. + arn: Processing job ARN. + image_uri: Container image URI from `AppSpecification.ImageUri`, + populated by `_describe_processing_job`. + tags: Resource tags, populated by `_list_tags_for_resource`. + """ + + name: str + region: str + arn: str + image_uri: Optional[str] = None + tags: Optional[list] = [] + + class ProductionVariant(BaseModel): name: str initial_instance_count: int diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json new file mode 100644 index 0000000000..99748671ae --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "aws", + "CheckID": "securityhub_delegated_admin_enabled_all_regions", + "CheckTitle": "Security Hub has delegated admin configured and is enabled in all regions with organization auto-enable", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices", + "Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices" + ], + "ServiceName": "securityhub", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AwsSecurityHubHub", + "ResourceGroup": "security", + "Description": "**AWS Security Hub** has a delegated administrator configured at the organization level, hubs are active in all opted-in regions, and organization auto-enable is active so that new member accounts are automatically enrolled.", + "Risk": "Without org-wide **AWS Security Hub** configuration, findings can be aggregated inconsistently, delegated admin may be missing in some regions, and new accounts will not be auto-enrolled. This fragments **security posture visibility**, delays **incident response**, and lets misconfigurations and compliance drift go undetected across the organization.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/securityhub/latest/userguide/designate-orgs-admin-account.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/accounts-orgs-auto-enable.html", + "https://docs.aws.amazon.com/securityhub/latest/userguide/securityhub-regions.html" + ], + "Remediation": { + "Code": { + "CLI": "aws securityhub enable-organization-admin-account --admin-account-id && aws securityhub update-organization-configuration --auto-enable --auto-enable-standards DEFAULT", + "NativeIaC": "", + "Other": "1. Sign in to the AWS Organizations management account\n2. Open the AWS Organizations console\n3. Navigate to Services > AWS Security Hub\n4. Click Register delegated administrator and enter the security account ID\n5. Switch to the delegated admin account\n6. In Security Hub console, go to Settings > Accounts\n7. Enable auto-enable for new organization accounts\n8. Repeat hub enablement for all opted-in regions", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **delegated administrator** for AWS Security Hub via AWS Organizations. Enable Security Hub in **all opted-in regions** and turn on **auto-enable** so new member accounts are automatically enrolled. This ensures uniform security posture monitoring across the entire organization.", + "Url": "https://hub.prowler.com/check/securityhub_delegated_admin_enabled_all_regions" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [ + "securityhub_enabled", + "guardduty_delegated_admin_enabled_all_regions" + ], + "Notes": "This check requires execution from the organization management account or delegated administrator account to access organization-level APIs." +} diff --git a/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py new file mode 100644 index 0000000000..4828752183 --- /dev/null +++ b/prowler/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions.py @@ -0,0 +1,84 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.securityhub.securityhub_client import ( + securityhub_client, +) + + +class securityhub_delegated_admin_enabled_all_regions(Check): + """Ensure Security Hub has a delegated admin and is enabled in all regions. + + This check verifies that: + 1. A delegated administrator account is configured for Security Hub + 2. Security Hub is active (ACTIVE status) in each region + 3. Organization auto-enable is configured for new member accounts + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check for each region. + """ + findings = [] + + # Build a set of regions that have an organization admin account configured + regions_with_admin = { + admin.region + for admin in securityhub_client.organization_admin_accounts + if admin.admin_status == "ENABLED" + } + admin_lookup_failed = securityhub_client.organization_admin_lookup_failed + + for securityhub in securityhub_client.securityhubs: + report = Check_Report_AWS(metadata=self.metadata(), resource=securityhub) + + # Check if this region has a delegated admin + has_delegated_admin = securityhub.region in regions_with_admin + + # Check if hub is active + hub_active = securityhub.status == "ACTIVE" + + # Check if auto-enable is configured for organization members + auto_enable_on = securityhub.organization_auto_enable + + # Determine overall status + issues = [] + if admin_lookup_failed: + issues.append("delegated administrator status could not be determined") + elif not has_delegated_admin: + issues.append("no delegated administrator configured") + if not hub_active: + issues.append("Security Hub not enabled") + if ( + hub_active + and securityhub.organization_config_available + and not auto_enable_on + ): + # Only report auto-enable issue if hub is active and org config data + # is available (i.e., we could actually read AutoEnable from the API). + issues.append("organization auto-enable not configured") + + if issues: + report.status = "FAIL" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has issues: " + f"{', '.join(issues)}." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Security Hub in region {securityhub.region} has delegated " + f"admin configured with hub active and organization auto-enable " + f"enabled." + ) + + # Support muting non-default regions if configured + if report.status == "FAIL" and ( + securityhub_client.audit_config.get("mute_non_default_regions", False) + and securityhub.region != securityhub_client.region + ): + report.muted = True + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/securityhub/securityhub_service.py b/prowler/providers/aws/services/securityhub/securityhub_service.py index 0799c6e048..5e2d445742 100644 --- a/prowler/providers/aws/services/securityhub/securityhub_service.py +++ b/prowler/providers/aws/services/securityhub/securityhub_service.py @@ -13,8 +13,14 @@ class SecurityHub(AWSService): # Call AWSService's __init__ super().__init__(__class__.__name__, provider) self.securityhubs = [] + self.organization_admin_accounts = [] + self.organization_admin_lookup_failed: bool = False self.__threading_call__(self._describe_hub) self.__threading_call__(self._list_tags, self.securityhubs) + self.__threading_call__(self._list_organization_admin_accounts) + self.__threading_call__( + self._describe_organization_configuration, self.securityhubs + ) def _describe_hub(self, regional_client): logger.info("SecurityHub - Describing Hub...") @@ -104,6 +110,95 @@ class SecurityHub(AWSService): f"{resource.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _list_organization_admin_accounts(self, regional_client): + """List Security Hub delegated administrator accounts for the organization. + + This API is only available to the organization management account or + a delegated administrator account. + """ + logger.info("SecurityHub - listing organization admin accounts...") + try: + paginator = regional_client.get_paginator( + "list_organization_admin_accounts" + ) + for page in paginator.paginate(): + for admin in page.get("AdminAccounts", []): + admin_account = OrganizationAdminAccount( + admin_account_id=admin.get("AdminAccountId"), + admin_status=admin.get("AdminStatus"), + region=regional_client.region, + ) + # Avoid duplicates across regions for the same admin account + if not any( + existing.admin_account_id == admin_account.admin_account_id + and existing.region == admin_account.region + for existing in self.organization_admin_accounts + ): + self.organization_admin_accounts.append(admin_account) + except ClientError as error: + self.organization_admin_lookup_failed = True + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + logger.warning( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self.organization_admin_lookup_failed = True + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _describe_organization_configuration(self, securityhub): + """Describe the organization configuration for a Security Hub instance. + + This provides information about auto-enable settings for the organization. + Only invoked for hubs in ACTIVE status. + """ + logger.info("SecurityHub - describing organization configuration...") + try: + if securityhub.status != "ACTIVE": + return + regional_client = self.regional_clients[securityhub.region] + org_config = regional_client.describe_organization_configuration() + securityhub.organization_auto_enable = org_config.get("AutoEnable", False) + securityhub.auto_enable_standards = org_config.get( + "AutoEnableStandards", "NONE" + ) + securityhub.organization_config_available = True + except ClientError as error: + if error.response["Error"]["Code"] in ( + "AccessDeniedException", + "InvalidAccessException", + "BadRequestException", + ): + # Expected when not running from management or delegated admin account + logger.warning( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + else: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{securityhub.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class OrganizationAdminAccount(BaseModel): + """Represents a Security Hub delegated administrator account.""" + + admin_account_id: str + admin_status: str # ENABLED or DISABLE_IN_PROGRESS + region: str + class SecurityHubHub(BaseModel): arn: str @@ -112,4 +207,8 @@ class SecurityHubHub(BaseModel): standards: str integrations: str region: str - tags: Optional[list] + tags: Optional[list] = [] + # Organization configuration fields + organization_auto_enable: bool = False + auto_enable_standards: str = "NONE" + organization_config_available: bool = False diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/__init__.py b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json new file mode 100644 index 0000000000..773c85b7ab --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "aws", + "CheckID": "transfer_server_pqc_ssh_kex_enabled", + "CheckTitle": "AWS Transfer Family server uses a post-quantum hybrid SSH key exchange security policy", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "transfer", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsTransferServer", + "ResourceGroup": "network", + "Description": "**AWS Transfer Family servers** (SFTP, FTPS, AS2) are assessed for use of an approved **post-quantum (PQ) hybrid SSH key exchange security policy**. Servers whose `SecurityPolicyName` is not in the configured allowlist of PQ-ready Transfer Family security policies leave file-transfer sessions exposed to **harvest-now, decrypt-later** attacks.", + "Risk": "Without a PQ-ready security policy, SSH/SFTP traffic captured today can be stored and decrypted in the future once a **cryptographically relevant quantum computer** is available. This threatens long-term **confidentiality** of transferred files, credentials, and metadata.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/transfer/latest/userguide/post-quantum-security-policies.html", + "https://docs.aws.amazon.com/transfer/latest/userguide/security-policies.html", + "https://aws.amazon.com/security/post-quantum-cryptography/", + "https://csrc.nist.gov/projects/post-quantum-cryptography" + ], + "Remediation": { + "Code": { + "CLI": "aws transfer update-server --server-id --security-policy-name TransferSecurityPolicy-2025-03", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::Transfer::Server\n Properties:\n Protocols:\n - SFTP\n SecurityPolicyName: TransferSecurityPolicy-2025-03 # FIX: post-quantum hybrid SSH KEX policy\n```", + "Other": "1. In the AWS Console, go to AWS Transfer Family > Servers\n2. Select the server and choose Edit on the Additional details panel\n3. Set Cryptographic algorithm options (Security policy) to TransferSecurityPolicy-2025-03 (or another approved PQ policy)\n4. Save the changes", + "Terraform": "```hcl\nresource \"aws_transfer_server\" \"\" {\n protocols = [\"SFTP\"]\n security_policy_name = \"TransferSecurityPolicy-2025-03\" # FIX: post-quantum hybrid SSH KEX policy\n endpoint_type = \"PUBLIC\"\n}\n```" + }, + "Recommendation": { + "Text": "Migrate AWS Transfer Family servers to a **post-quantum security policy** (e.g., `TransferSecurityPolicy-2025-03`, `TransferSecurityPolicy-FIPS-2025-03`, `TransferSecurityPolicy-AS2Restricted-2025-07`) that adds ML-KEM hybrid SSH key exchange. Avoid deprecated `*-PQ-SSH-Experimental-2023-04` policies. Review allowed policies regularly as AWS publishes new PQ-ready options.", + "Url": "https://hub.prowler.com/check/transfer_server_pqc_ssh_kex_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [ + "transfer_server_in_transit_encryption_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py new file mode 100644 index 0000000000..a7d70df2df --- /dev/null +++ b/prowler/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled.py @@ -0,0 +1,50 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.transfer.transfer_client import transfer_client + +PQC_TRANSFER_POLICIES_DEFAULT = [ + "TransferSecurityPolicy-2025-03", + "TransferSecurityPolicy-FIPS-2025-03", + "TransferSecurityPolicy-AS2Restricted-2025-07", +] + + +class transfer_server_pqc_ssh_kex_enabled(Check): + """Verify that every AWS Transfer Family server uses a post-quantum security policy. + + A Transfer Family server PASSES when its ``SecurityPolicyName`` is in the + configured allowlist of policies that enable hybrid ML-KEM SSH key exchange. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Check whether Transfer Family servers use approved PQ SSH KEX policies. + + Iterates through discovered AWS Transfer Family servers and compares each + server's ``SecurityPolicyName`` with the configured allowlist of + post-quantum hybrid SSH key exchange security policies. + + Returns: + list[Check_Report_AWS]: A list of reports for each Transfer Family + server, including the evaluated security policy context. + """ + findings = [] + pqc_policies = transfer_client.audit_config.get( + "transfer_pqc_ssh_allowed_policies", PQC_TRANSFER_POLICIES_DEFAULT + ) + for server in transfer_client.servers.values(): + report = Check_Report_AWS(metadata=self.metadata(), resource=server) + policy = server.security_policy_name or "" + if server.security_policy_name in pqc_policies: + report.status = "PASS" + report.status_extended = ( + f"Transfer Server {server.id} uses post-quantum security policy " + f"{policy}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Transfer Server {server.id} uses security policy {policy}, " + "which does not enable post-quantum hybrid SSH key exchange." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/transfer/transfer_service.py b/prowler/providers/aws/services/transfer/transfer_service.py index f86e195a31..968b0c9e60 100644 --- a/prowler/providers/aws/services/transfer/transfer_service.py +++ b/prowler/providers/aws/services/transfer/transfer_service.py @@ -46,6 +46,9 @@ class Transfer(AWSService): ) for protocol in server_description.get("Protocols", []): server.protocols.append(Protocol(protocol)) + server.security_policy_name = server_description.get( + "SecurityPolicyName", "" + ) server.tags = server_description.get("Tags", []) except Exception as error: logger.error( @@ -65,4 +68,5 @@ class Server(BaseModel): id: str region: str protocols: List[Protocol] = Field(default_factory=list) + security_policy_name: str = "" tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json new file mode 100644 index 0000000000..77096d6a20 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_auto_upgrade_enabled", + "CheckTitle": "AKS cluster has automatic upgrade enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**AKS clusters** are evaluated for an **automatic upgrade channel** other than `none`. Automatic upgrades keep the Kubernetes control plane and node pools on supported patch/minor versions and reduce version drift across clusters.", + "Risk": "Without automatic upgrades, AKS clusters can remain on unsupported or vulnerable Kubernetes versions. Delayed patching increases exposure to **known CVEs**, control plane/node defects, and exploit chains that can affect workload **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/aks/auto-upgrade-cluster", + "https://learn.microsoft.com/en-us/azure/aks/supported-kubernetes-versions" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --auto-upgrade-channel patch", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure an AKS automatic upgrade channel so clusters receive Kubernetes version updates and security patches without relying only on manual upgrade processes. Use `patch` or `stable` for conservative production upgrades, reserve faster channels such as `rapid` for environments that can absorb quicker version changes, and avoid `none` unless a documented exception and manual patching process exists.", + "Url": "https://hub.prowler.com/check/aks_cluster_auto_upgrade_enabled" + } + }, + "Categories": [ + "vulnerabilities", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Automatic upgrade channels can change cluster versions during Azure-managed upgrade windows. Select a channel that matches the workload change tolerance and use planned maintenance windows, staging validation, and documented rollback procedures for production clusters." +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py new file mode 100644 index 0000000000..9d1f8b48a1 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_auto_upgrade_enabled(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + auto_upgrade_channel = ( + (cluster.auto_upgrade_channel or "").strip().lower() + ) + if auto_upgrade_channel and auto_upgrade_channel != "none": + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has auto-upgrade channel." + ) + else: + report.status = "FAIL" + report.status_extended = f"Cluster '{cluster.name}' does not have auto-upgrade configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json new file mode 100644 index 0000000000..362b8e4d73 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_azure_monitor_enabled", + "CheckTitle": "AKS cluster has Azure Monitor metrics enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**Azure Kubernetes Service** clusters are evaluated for **Azure Monitor managed service for Prometheus** (the cluster's `azureMonitorProfile.metrics` configuration). When enabled, cluster and workload metrics are collected into an Azure Monitor workspace for dashboards and alerting. This is distinct from Container Insights (the `omsagent` logs addon).", + "Risk": "Without **Azure Monitor metrics**, cluster degradation, resource exhaustion, and anomalous behavior go undetected. Lack of metric-based observability delays incident detection and hampers investigation after a security or availability event.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/kubernetes-monitoring-enable", + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/prometheus-metrics-enable", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --enable-azure-monitor-metrics", + "NativeIaC": "```bicep\n// Bicep: AKS cluster with Azure Monitor managed Prometheus metrics enabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-02-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n azureMonitorProfile: {\n metrics: {\n enabled: true // CRITICAL: enables Azure Monitor managed service for Prometheus (metrics)\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your AKS cluster\n2. In the left menu, select Monitoring > Insights\n3. Click Configure monitoring\n4. Ensure 'Enable Prometheus metrics' is selected and choose (or create) an Azure Monitor workspace\n5. Click Configure to enable Azure Monitor managed Prometheus metrics", + "Terraform": "```hcl\n# Terraform: AKS cluster with Azure Monitor managed Prometheus metrics enabled\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n monitor_metrics {} # CRITICAL: enables Azure Monitor managed service for Prometheus (metrics)\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Azure Monitor managed service for Prometheus** on AKS clusters so cluster and workload metrics are collected into an Azure Monitor workspace. Pair it with alerting on cluster health and resource saturation, and consider also enabling Container Insights (logs) for full observability.", + "Url": "https://hub.prowler.com/check/aks_cluster_azure_monitor_enabled" + } + }, + "Categories": [ + "logging" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py new file mode 100644 index 0000000000..0c47f6373c --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled.py @@ -0,0 +1,32 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_azure_monitor_enabled(Check): + """ + Ensure Azure Monitor is enabled for AKS clusters. + + This check evaluates whether each Azure Kubernetes Service cluster has Azure Monitor integration enabled for metrics collection, log aggregation, and alerting. + + - PASS: The cluster has Azure Monitor enabled. + - FAIL: The cluster does not have Azure Monitor enabled. + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.azure_monitor_enabled: + report.status = "PASS" + report.status_extended = f"Cluster '{cluster.name}' has Azure Monitor managed Prometheus metrics enabled." + else: + report.status = "FAIL" + report.status_extended = f"Cluster '{cluster.name}' does not have Azure Monitor managed Prometheus metrics enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json new file mode 100644 index 0000000000..90bbd45f3b --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_defender_enabled", + "CheckTitle": "AKS cluster has Microsoft Defender security profile enabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**AKS clusters** are evaluated for the **Microsoft Defender security profile** (`securityProfile.defender.securityMonitoring.enabled=true`). Defender for Containers extends security monitoring, threat detection, vulnerability assessment, and security posture insights to Azure Kubernetes Service workloads.", + "Risk": "Without the Microsoft Defender security profile, AKS runtime threats such as **cryptomining**, suspicious Kubernetes API activity, vulnerable container images, and malicious workload behavior may go undetected. Compromised containers can pivot to cluster resources and cloud APIs, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-deployment-overview", + "https://learn.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-update", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --enable-defender", + "NativeIaC": "```bicep\n// AKS cluster with Microsoft Defender security monitoring enabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-05-01' = {\n name: ''\n location: ''\n identity: {\n type: 'SystemAssigned'\n }\n properties: {\n dnsPrefix: ''\n securityProfile: {\n defender: {\n logAnalyticsWorkspaceResourceId: ''\n securityMonitoring: {\n enabled: true // Critical: enables Defender security monitoring for AKS\n }\n }\n }\n agentPoolProfiles: [\n {\n name: 'system'\n count: 1\n vmSize: 'Standard_DS2_v2'\n mode: 'System'\n }\n ]\n }\n}\n```", + "Other": "1. In Microsoft Defender for Cloud, enable the Containers plan for the subscription that contains the AKS cluster\n2. In the AKS cluster configuration, enable the Microsoft Defender security profile\n3. Verify that Defender security monitoring is enabled for the managed cluster", + "Terraform": "```hcl\nresource \"azurerm_log_analytics_workspace\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n sku = \"PerGB2018\"\n}\n\nresource \"azurerm_kubernetes_cluster\" \"example\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n\n default_node_pool {\n name = \"system\"\n node_count = 1\n vm_size = \"Standard_DS2_v2\"\n }\n\n identity {\n type = \"SystemAssigned\"\n }\n\n microsoft_defender {\n log_analytics_workspace_id = azurerm_log_analytics_workspace.example.id\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable the Microsoft Defender security profile for AKS clusters and ensure the subscription has Defender for Containers enabled. Route security monitoring data to the appropriate workspace and review Defender alerts and recommendations as part of the cluster operations process.", + "Url": "https://hub.prowler.com/check/aks_cluster_defender_enabled" + } + }, + "Categories": [ + "threat-detection", + "cluster-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates the AKS managed cluster Defender security monitoring flag. Defender for Containers plan availability, billing, workspace routing, and alert response processes should be validated separately at the subscription and security operations levels." +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py new file mode 100644 index 0000000000..08074754a6 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_defender_enabled(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.defender_enabled is True: + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has Defender for Containers " + "enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Cluster '{cluster.name}' does not have Defender for " + "Containers enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/__init__.py b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json new file mode 100644 index 0000000000..5577d4799c --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aks_cluster_local_accounts_disabled", + "CheckTitle": "AKS cluster has local accounts disabled", + "CheckType": [], + "ServiceName": "aks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.containerservice/managedclusters", + "ResourceGroup": "container", + "Description": "**Azure Kubernetes Service** clusters are evaluated for **local account** status. Disabling local accounts forces all cluster authentication through Microsoft Entra ID, ensuring centralized identity management, MFA, and conditional access.", + "Risk": "Local accounts bypass **Entra ID** authentication, MFA, and conditional access policies. Compromised local credentials (such as the static cluster-admin certificate) provide persistent cluster access without an identity-based audit trail or governance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/aks/manage-local-accounts-managed-azure-ad", + "https://learn.microsoft.com/en-us/cli/azure/aks?view=azure-cli-latest#az-aks-update", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.containerservice/managedclusters" + ], + "Remediation": { + "Code": { + "CLI": "az aks update --resource-group --name --disable-local-accounts", + "NativeIaC": "```bicep\n// Bicep: AKS cluster with local accounts disabled\nresource aks 'Microsoft.ContainerService/managedClusters@2024-02-01' = {\n name: ''\n location: resourceGroup().location\n properties: {\n disableLocalAccounts: true // CRITICAL: forces authentication through Entra ID\n aadProfile: {\n managed: true\n enableAzureRbac: true\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your AKS cluster\n2. Ensure Microsoft Entra ID (AAD) authentication is configured for the cluster\n3. Under Settings, select Cluster configuration (or Authentication and Authorization)\n4. Set Local accounts to Disabled\n5. Save the configuration", + "Terraform": "```hcl\n# Terraform: AKS cluster with local accounts disabled\nresource \"azurerm_kubernetes_cluster\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n dns_prefix = \"\"\n local_account_disabled = true # CRITICAL: forces authentication through Entra ID\n\n azure_active_directory_role_based_access_control {\n azure_rbac_enabled = true\n }\n}\n```" + }, + "Recommendation": { + "Text": "Disable local accounts on AKS clusters and require **Microsoft Entra ID** authentication with Azure RBAC. Before disabling, confirm Entra ID integration and break-glass access are in place so administrators retain authenticated access, then enforce MFA and conditional access on cluster identities.", + "Url": "https://hub.prowler.com/check/aks_cluster_local_accounts_disabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py new file mode 100644 index 0000000000..85670e0d15 --- /dev/null +++ b/prowler/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aks.aks_client import aks_client + + +class aks_cluster_local_accounts_disabled(Check): + """ + Ensure local accounts are disabled on AKS clusters. + + This check evaluates whether each Azure Kubernetes Service cluster has local accounts disabled, forcing all authentication through Microsoft Entra ID. + + - PASS: The cluster has local accounts disabled. + - FAIL: The cluster has local accounts enabled. + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription_name, clusters in aks_client.clusters.items(): + for cluster in clusters.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=cluster) + report.subscription = subscription_name + + if cluster.local_accounts_disabled: + report.status = "PASS" + report.status_extended = ( + f"Cluster '{cluster.name}' has local accounts disabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Cluster '{cluster.name}' has local accounts enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/aks/aks_service.py b/prowler/providers/azure/services/aks/aks_service.py index 3d158a2f70..081edd7b17 100644 --- a/prowler/providers/azure/services/aks/aks_service.py +++ b/prowler/providers/azure/services/aks/aks_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from azure.mgmt.containerservice import ContainerServiceClient @@ -55,6 +55,56 @@ class AKS(AzureService): ) ], rbac_enabled=getattr(cluster, "enable_rbac", False), + auto_upgrade_channel=getattr( + getattr(cluster, "auto_upgrade_profile", None), + "upgrade_channel", + None, + ), + defender_enabled=bool( + getattr( + getattr( + getattr( + getattr( + cluster, + "security_profile", + None, + ), + "defender", + None, + ), + "security_monitoring", + None, + ), + "enabled", + False, + ) + ), + azure_monitor_enabled=( + bool( + getattr( + getattr( + getattr( + cluster, + "azure_monitor_profile", + None, + ), + "metrics", + None, + ), + "enabled", + False, + ) + ) + if getattr( + cluster, "azure_monitor_profile", None + ) + else False + ), + local_accounts_disabled=bool( + getattr( + cluster, "disable_local_accounts", False + ) + ), ) } ) @@ -82,3 +132,7 @@ class Cluster: agent_pool_profiles: List[ManagedClusterAgentPoolProfile] rbac_enabled: bool location: str + auto_upgrade_channel: Optional[str] = None + defender_enabled: bool = False + azure_monitor_enabled: bool = False + local_accounts_disabled: bool = False diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json new file mode 100644 index 0000000000..41246ff7a6 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_automatic_failover_enabled", + "CheckTitle": "Cosmos DB account has automatic failover enabled", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **automatic failover** configuration. When enabled, Cosmos DB automatically promotes a secondary region to primary during a regional outage, ensuring continuous availability without manual intervention.", + "Risk": "Without **automatic failover**, a regional outage requires **manual failover** which delays recovery and risks data unavailability. Applications dependent on the primary region experience downtime until an operator intervenes.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-manage-database-account#automatic-failover", + "https://learn.microsoft.com/en-us/azure/cosmos-db/high-availability", + "https://learn.microsoft.com/en-us/azure/cosmos-db/distribute-data-globally" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --enable-automatic-failover true", + "NativeIaC": "```bicep\n// Bicep: Enable automatic failover on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [\n { locationName: '', failoverPriority: 0 }\n { locationName: '', failoverPriority: 1 }\n ]\n enableAutomaticFailover: true // Critical: Promotes a secondary region during a primary region outage\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Replicate data globally\n3. Click Automatic Failover\n4. Toggle Enable Automatic Failover to On\n5. Set failover priorities for each region\n6. Click Save", + "Terraform": "```hcl\n# Terraform: Enable automatic failover on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n geo_location {\n location = \"\"\n failover_priority = 1\n }\n\n enable_automatic_failover = true # Critical: Promotes a secondary region during a primary region outage\n}\n```" + }, + "Recommendation": { + "Text": "Enable **automatic failover** on Cosmos DB accounts with **multi-region** deployments so a secondary region is promoted automatically when the primary region becomes unavailable. Configure **failover priorities** to reflect your recovery strategy, validate **RTO/RPO** expectations with periodic failover drills, and combine with **multi-region writes** where active-active is required.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_automatic_failover_enabled" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py new file mode 100644 index 0000000000..7d7d2e5023 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_automatic_failover_enabled(Check): + """Ensure that Cosmos DB accounts have automatic failover enabled.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB automatic failover check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `enableAutomaticFailover` is True, FAIL otherwise. + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not have automatic failover enabled." + if account.enable_automatic_failover: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has automatic failover enabled." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json new file mode 100644 index 0000000000..1631aa5b3a --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_backup_policy_continuous", + "CheckTitle": "Cosmos DB account uses continuous backup policy", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **continuous backup** policy. Continuous backup provides **point-in-time restore (PITR)** enabling recovery to any point within the retention window, unlike periodic backup which only supports full restores at fixed intervals.", + "Risk": "**Periodic backup** limits recovery to the last backup snapshot. Data changes between snapshots are lost during restore. **Continuous backup** enables **granular recovery** from accidental deletes, corruption, or ransomware.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/continuous-backup-restore-introduction", + "https://learn.microsoft.com/en-us/azure/cosmos-db/migrate-continuous-backup", + "https://learn.microsoft.com/en-us/azure/cosmos-db/restore-account-continuous-backup" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --backup-policy-type Continuous --continuous-tier Continuous30Days", + "NativeIaC": "```bicep\n// Bicep: Switch a Cosmos DB account to Continuous backup (irreversible)\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n backupPolicy: {\n type: 'Continuous' // Critical: Enables point-in-time restore. Migration from Periodic is one-way.\n continuousModeProperties: {\n tier: 'Continuous30Days' // or 'Continuous7Days' for the lower-cost tier\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Backup & Restore\n3. Click Switch to Continuous backup\n4. Select the retention tier (Continuous30Days or Continuous7Days)\n5. Acknowledge that the migration from Periodic to Continuous is irreversible\n6. Click Save", + "Terraform": "```hcl\n# Terraform: Configure Cosmos DB account with Continuous backup (irreversible)\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n backup {\n type = \"Continuous\" # Critical: Enables point-in-time restore. One-way migration from Periodic.\n tier = \"Continuous30Days\" # or \"Continuous7Days\" for the lower-cost tier\n }\n}\n```" + }, + "Recommendation": { + "Text": "Use **Continuous backup** for Cosmos DB accounts that require **point-in-time restore (PITR)**. Pick the retention tier (`Continuous7Days` or `Continuous30Days`) based on recovery objectives, and validate restore procedures with periodic drills. Note that switching from **Periodic** to **Continuous** is a **one-way** migration; plan the change and review pricing before applying.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_backup_policy_continuous" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py new file mode 100644 index 0000000000..f1a00fc2e8 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_backup_policy_continuous(Check): + """Ensure that Cosmos DB accounts use the continuous backup policy.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB continuous-backup check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `backupPolicy.type` is `Continuous`, FAIL otherwise (including + when the property is missing). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not use continuous backup policy." + if account.backup_policy_type == "Continuous": + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} uses continuous backup policy." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json new file mode 100644 index 0000000000..f9ddb2baa0 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_minimum_tls_version", + "CheckTitle": "Cosmos DB account enforces TLS 1.2 or higher", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **minimum TLS version**. TLS 1.0 and 1.1 are deprecated and contain known weaknesses. Enforcing **TLS 1.2** ensures all client connections negotiate modern, secure encryption protocols.", + "Risk": "Allowing **TLS 1.0/1.1** exposes client connections to **POODLE**, **BEAST**, and other protocol downgrade attacks that can compromise the **confidentiality** and **integrity** of data in transit, and may enable credential interception via weakened cipher suites.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/security", + "https://learn.microsoft.com/en-us/cli/azure/cosmosdb", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.documentdb/databaseaccounts" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --minimal-tls-version Tls12", + "NativeIaC": "```bicep\n// Bicep: Enforce minimum TLS 1.2 on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n minimalTlsVersion: 'Tls12' // Critical: Rejects client connections negotiating TLS 1.0 or 1.1\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Networking\n3. Locate the Minimum TLS version setting\n4. Select TLS 1.2\n5. Click Save\n6. Validate that all client applications support TLS 1.2 before enforcing the change", + "Terraform": "```hcl\n# Terraform: Enforce minimum TLS 1.2 on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n minimal_tls_version = \"Tls12\" # Critical: Rejects client connections negotiating TLS 1.0 or 1.1\n}\n```" + }, + "Recommendation": { + "Text": "Set the Cosmos DB account **minimum TLS version** to at least **1.2** to block legacy protocols (`TLS 1.0`/`1.1`) vulnerable to known downgrade and cipher attacks. Inventory and update **client SDKs** and **drivers** to support TLS 1.2 prior to enforcement, and pair this control with **private endpoints**, **AAD/RBAC authentication**, and **handshake failure monitoring** to identify outdated clients.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_minimum_tls_version" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py new file mode 100644 index 0000000000..5d85072367 --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_minimum_tls_version(Check): + """Ensure that Cosmos DB accounts enforce TLS 1.2 or higher.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB minimum TLS version check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `minimalTlsVersion` is `Tls12` or higher, FAIL otherwise + (including when the property is missing or set to a legacy value). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not enforce TLS 1.2 or higher." + if account.minimal_tls_version in {"Tls12", "Tls13"}: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} enforces TLS 1.2 or higher." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..2d8a61a54e --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "cosmosdb_account_public_network_access_disabled", + "CheckTitle": "Cosmos DB account has public network access disabled", + "CheckType": [], + "ServiceName": "cosmosdb", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.documentdb/databaseaccounts", + "ResourceGroup": "database", + "Description": "**Azure Cosmos DB accounts** are evaluated for **public network access**. Disabling public network access ensures the account is only reachable through **private endpoints** or **VNet service endpoints**, reducing the attack surface and preventing direct exposure of the data plane to the internet.", + "Risk": "Allowing **public network access** exposes the Cosmos DB data plane to the internet. Combined with leaked **connection strings**, weak **firewall rules**, or compromised **AAD tokens**, this enables **unauthorized data access**, **enumeration**, **brute force**, and **data exfiltration** from any source on the internet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-private-endpoints", + "https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-configure-firewall", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.documentdb/databaseaccounts" + ], + "Remediation": { + "Code": { + "CLI": "az cosmosdb update --name --resource-group --public-network-access Disabled", + "NativeIaC": "```bicep\n// Bicep: Disable public network access on a Cosmos DB account\nresource account 'Microsoft.DocumentDB/databaseAccounts@2025-10-15' = {\n name: ''\n location: resourceGroup().location\n kind: 'GlobalDocumentDB'\n properties: {\n databaseAccountOfferType: 'Standard'\n locations: [{ locationName: resourceGroup().location }]\n publicNetworkAccess: 'Disabled' // Critical: Blocks all public-internet traffic to the data plane\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Cosmos DB account\n2. In the left menu, select Networking\n3. Under Public network access, select Disabled\n4. Configure private endpoints or VNet service endpoints before saving so clients retain connectivity\n5. Click Save", + "Terraform": "```hcl\n# Terraform: Disable public network access on a Cosmos DB account\nresource \"azurerm_cosmosdb_account\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n offer_type = \"Standard\"\n kind = \"GlobalDocumentDB\"\n\n geo_location {\n location = \"\"\n failover_priority = 0\n }\n\n public_network_access_enabled = false # Critical: Blocks all public-internet traffic to the data plane\n}\n```" + }, + "Recommendation": { + "Text": "Disable **public network access** on Cosmos DB accounts and require connectivity via **private endpoints** or **VNet service endpoints**. Before enforcement, validate that all application workloads have private-network connectivity, and pair this control with **AAD/RBAC authentication**, **TLS 1.2+**, and **firewall rules** restricted to the minimum trusted ranges to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/cosmosdb_account_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py new file mode 100644 index 0000000000..ec2d29728d --- /dev/null +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.cosmosdb.cosmosdb_client import cosmosdb_client + + +class cosmosdb_account_public_network_access_disabled(Check): + """Ensure that Cosmos DB accounts have public network access disabled.""" + + def execute(self) -> Check_Report_Azure: + """Execute the Cosmos DB public network access check. + + Iterates over every Cosmos DB account fetched by the service and reports + PASS when `publicNetworkAccess` is `Disabled` or `SecuredByPerimeter` + (Microsoft Network Security Perimeter), FAIL otherwise (including when + the property is missing or set to `Enabled`). + + Returns: + A list of Check_Report_Azure with one report per Cosmos DB account. + """ + findings = [] + for subscription, accounts in cosmosdb_client.accounts.items(): + for account in accounts: + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription + report.status = "FAIL" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} does not have public network access disabled (current value: {account.public_network_access!r})." + if account.public_network_access in {"Disabled", "SecuredByPerimeter"}: + report.status = "PASS" + report.status_extended = f"CosmosDB account {account.name} from subscription {subscription} has public network access disabled." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py index c36af1d2e9..e7c53799a7 100644 --- a/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py +++ b/prowler/providers/azure/services/cosmosdb/cosmosdb_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from azure.mgmt.cosmosdb import CosmosDBManagementClient @@ -36,14 +36,29 @@ class CosmosDB(AzureService): name=private_endpoint_connection.name, type=private_endpoint_connection.type, ) - for private_endpoint_connection in getattr( - account, "private_endpoint_connections", [] + for private_endpoint_connection in ( + getattr(account, "private_endpoint_connections", []) + or [] ) if private_endpoint_connection ], disable_local_auth=getattr( account, "disable_local_auth", False ), + enable_automatic_failover=getattr( + account, "enable_automatic_failover", False + ), + backup_policy_type=getattr( + getattr(account, "backup_policy", None), + "type", + None, + ), + public_network_access=getattr( + account, "public_network_access", None + ), + minimal_tls_version=getattr( + account, "minimal_tls_version", None + ), ) ) except Exception as error: @@ -71,3 +86,7 @@ class Account: location: str private_endpoint_connections: List[PrivateEndpointConnection] disable_local_auth: bool = False + enable_automatic_failover: bool = False + backup_policy_type: Optional[str] = None + public_network_access: Optional[str] = None + minimal_tls_version: Optional[str] = None diff --git a/prowler/providers/azure/services/databricks/databricks_service.py b/prowler/providers/azure/services/databricks/databricks_service.py index 7920500d88..b7367d3cbb 100644 --- a/prowler/providers/azure/services/databricks/databricks_service.py +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -62,10 +62,21 @@ class Databricks(AzureService): else: managed_disk_encryption = None + enable_no_public_ip = getattr( + workspace_parameters, "enable_no_public_ip", None + ) workspaces[subscription][workspace.id] = DatabricksWorkspace( id=workspace.id, name=workspace.name, location=workspace.location, + public_network_access=getattr( + workspace, "public_network_access", None + ), + no_public_ip_enabled=( + getattr(enable_no_public_ip, "value", None) + if enable_no_public_ip + else None + ), custom_managed_vnet_id=( getattr( workspace_parameters, "custom_virtual_network_id", None @@ -107,6 +118,8 @@ class DatabricksWorkspace(BaseModel): id: The unique identifier of the workspace. name: The name of the workspace. location: The Azure region where the workspace is deployed. + public_network_access: Whether public network access is "Enabled" or "Disabled", if configured. + no_public_ip_enabled: Whether secure cluster connectivity (no public IP) is enabled. None when the workspace does not expose this classic-compute setting (e.g. serverless workspaces). custom_managed_vnet_id: The ID of the custom managed virtual network, if configured. managed_disk_encryption: The encryption settings for the workspace's managed disks. """ @@ -114,5 +127,7 @@ class DatabricksWorkspace(BaseModel): id: str name: str location: str + public_network_access: Optional[str] = None + no_public_ip_enabled: Optional[bool] = None custom_managed_vnet_id: Optional[str] = None managed_disk_encryption: Optional[ManagedDiskEncryption] = None diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json new file mode 100644 index 0000000000..4062199474 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_no_public_ip_enabled", + "CheckTitle": "Databricks workspace has secure cluster connectivity (no public IP)", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.databricks/workspaces", + "ResourceGroup": "ai_ml", + "Description": "**Azure Databricks workspaces** are evaluated for **secure cluster connectivity** (No Public IP / NPIP). When enabled, compute (cluster) nodes are deployed without public IP addresses and communicate with the control plane through a secure relay, reducing the workspace's exposure to the internet.", + "Risk": "Without **secure cluster connectivity**, Databricks compute nodes are assigned **public IP addresses** and are directly reachable from the internet. This enables **port scanning**, **lateral movement** from a compromised node, and **data exfiltration** that bypasses private-network controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/secure-cluster-connectivity", + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/vnet-inject", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.databricks/workspaces" + ], + "Remediation": { + "Code": { + "CLI": "az databricks workspace create --name --resource-group --location --sku premium --enable-no-public-ip", + "NativeIaC": "```bicep\n// Bicep: Deploy a Databricks workspace with secure cluster connectivity (No Public IP)\nresource workspace 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: resourceGroup().location\n sku: {\n name: 'premium'\n }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n parameters: {\n enableNoPublicIp: {\n value: true // CRITICAL: Deploys cluster nodes without public IP addresses\n }\n }\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and start creating a new Azure Databricks workspace (No Public IP must be set at creation; it cannot be enabled on an existing workspace)\n2. On the Networking tab, set Deploy Azure Databricks workspace with Secure Cluster Connectivity (No Public IP) to Yes\n3. Complete the VNet injection configuration if required\n4. Review + create the workspace\n5. Migrate workloads to the new workspace and decommission the old one", + "Terraform": "```hcl\n# Terraform: Databricks workspace with secure cluster connectivity (No Public IP)\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n custom_parameters {\n no_public_ip = true # CRITICAL: Deploys cluster nodes without public IP addresses\n }\n}\n```" + }, + "Recommendation": { + "Text": "Deploy Databricks workspaces with **secure cluster connectivity (No Public IP)** so compute nodes have no public IP addresses. Because this is set at creation, plan a migration for existing workspaces, and pair it with **VNet injection**, **private endpoints / disabled public network access**, and least-privilege NSG rules to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/databricks_workspace_no_public_ip_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Secure cluster connectivity (No Public IP) applies to classic compute and is set at workspace creation. Serverless workspaces do not expose this setting (they have no customer-managed cluster nodes with public IPs) and are reported as MANUAL for verification." +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py new file mode 100644 index 0000000000..13b7742ffa --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_no_public_ip_enabled(Check): + """ + Ensure Azure Databricks workspaces have secure cluster connectivity (no public IP) enabled. + + This check evaluates whether each Azure Databricks workspace in the subscription is deployed with secure cluster connectivity (No Public IP / NPIP), so cluster nodes are not assigned public IP addresses. + + Secure cluster connectivity is a classic-compute setting. Serverless workspaces do not expose it (they have no customer-managed cluster nodes with public IPs), so the workspace is reported as MANUAL for verification rather than failed. + + - PASS: The workspace has secure cluster connectivity enabled (no_public_ip_enabled is True). + - FAIL: The workspace has secure cluster connectivity disabled (no_public_ip_enabled is False). + - MANUAL: The workspace does not expose the setting (no_public_ip_enabled is None, e.g. serverless workspaces). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.no_public_ip_enabled is None: + report.status = "MANUAL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + elif workspace.no_public_ip_enabled: + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has secure cluster connectivity (no public IP) enabled." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) does not have secure cluster connectivity (no public IP) enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..cb92d6df0f --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_public_network_access_disabled", + "CheckTitle": "Databricks workspace has public network access disabled", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.databricks/workspaces", + "ResourceGroup": "ai_ml", + "Description": "**Azure Databricks workspaces** are evaluated for **public network access**. Disabling public network access ensures the workspace is only reachable through **private endpoints**, reducing the attack surface and preventing direct exposure of the control plane and data plane to the internet.", + "Risk": "Allowing **public network access** exposes the Databricks workspace control plane and data plane to the internet. Combined with leaked **credentials**, **personal access tokens**, or unpatched vulnerabilities, this enables **unauthorized access**, **brute force**, and **data exfiltration** from any source on the internet.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link", + "https://learn.microsoft.com/en-us/azure/databricks/security/network/classic/private-link#step-4-disable-public-network-access", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.databricks/workspaces" + ], + "Remediation": { + "Code": { + "CLI": "az databricks workspace update --name --resource-group --public-network-access Disabled", + "NativeIaC": "```bicep\n// Bicep: Disable public network access on a Databricks workspace\nresource workspace 'Microsoft.Databricks/workspaces@2023-02-01' = {\n name: ''\n location: resourceGroup().location\n sku: {\n name: 'premium'\n }\n properties: {\n managedResourceGroupId: '/subscriptions//resourceGroups/'\n publicNetworkAccess: 'Disabled' // CRITICAL: Blocks all public-internet traffic to the workspace\n }\n}\n```", + "Other": "1. Sign in to the Azure portal and open your Databricks workspace\n2. In the left menu, select Networking\n3. Under Public network access, select Disabled\n4. Configure Azure Private Link private endpoints before saving so clients retain connectivity\n5. Click Save", + "Terraform": "```hcl\n# Terraform: Disable public network access on a Databricks workspace\nresource \"azurerm_databricks_workspace\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku = \"premium\"\n\n public_network_access_enabled = false # CRITICAL: Blocks all public-internet traffic to the workspace\n}\n```" + }, + "Recommendation": { + "Text": "Disable **public network access** on Databricks workspaces and require connectivity via **Azure Private Link** private endpoints. Before enforcement, validate that all application workloads have private-network connectivity, and pair this control with **VNet injection**, **secure cluster connectivity (no public IP)**, and least-privilege access to uphold **defense in depth**.", + "Url": "https://hub.prowler.com/check/databricks_workspace_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py new file mode 100644 index 0000000000..e8193cedb6 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_public_network_access_disabled(Check): + """ + Ensure Azure Databricks workspaces have public network access disabled. + + This check evaluates whether each Azure Databricks workspace in the subscription restricts connectivity to private endpoints by disabling public network access. + + - PASS: The workspace has public network access disabled (public_network_access is "Disabled"). + - FAIL: The workspace has public network access enabled (or the value is not set). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + subscription_name = databricks_client.subscriptions.get( + subscription, subscription + ) + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.public_network_access == "Disabled": + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has public network access disabled." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription_name} ({subscription}) has public network access enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json new file mode 100644 index 0000000000..cae92490af --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "defender_ensure_defender_cspm_is_on", + "CheckTitle": "Microsoft Defender CSPM is set to On", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.security/pricings", + "ResourceGroup": "security", + "Description": "**Microsoft Defender for Cloud** Cloud Security Posture Management (CSPM) plan is evaluated for **standard tier** activation. Defender CSPM provides advanced posture management capabilities including attack path analysis, cloud security explorer, agentless scanning, and governance rules.", + "Risk": "Without Defender CSPM, the subscription relies on **foundational CSPM** (free tier) which lacks attack path analysis, agentless vulnerability scanning, and security governance. Advanced threats exploiting misconfiguration chains go undetected.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/concept-cloud-security-posture-management", + "https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-enhanced-security" + ], + "Remediation": { + "Code": { + "CLI": "az security pricing create -n CloudPosture --tier Standard", + "NativeIaC": "```bicep\ntargetScope = 'subscription'\n\nresource defenderCSPM 'Microsoft.Security/pricings@2024-01-01' = {\n name: 'CloudPosture'\n properties: {\n pricingTier: 'Standard' // Critical: enables Defender CSPM\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Go to Microsoft Defender for Cloud\n3. Select Environment Settings\n4. Click on the subscription\n5. Set Cloud Security Posture Management (CSPM) to On\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_security_center_subscription_pricing\" \"\" {\n tier = \"Standard\" # Critical: enables Defender CSPM\n resource_type = \"CloudPosture\"\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Defender CSPM** standard tier for advanced cloud security posture management. Evaluate the cost against the security benefits \u2014 CSPM provides attack path analysis and agentless scanning.", + "Url": "https://hub.prowler.com/check/defender_ensure_defender_cspm_is_on" + } + }, + "Categories": [ + "threat-detection" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py new file mode 100644 index 0000000000..cf7957400c --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.defender.defender_client import defender_client + + +class defender_ensure_defender_cspm_is_on(Check): + """ + Ensure Microsoft Defender Cloud Security Posture Management (CSPM) is set to On. + + This check evaluates whether the Defender CSPM plan (CloudPosture pricing) is enabled with the Standard tier for each subscription. + + - PASS: The CloudPosture pricing tier is "Standard" (Defender CSPM is on). + - FAIL: The CloudPosture pricing tier is not "Standard" (Defender CSPM is off). + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription, pricings in defender_client.pricings.items(): + subscription_name = defender_client.subscriptions.get( + subscription, subscription + ) + if "CloudPosture" in pricings: + report = Check_Report_Azure( + metadata=self.metadata(), + resource=pricings["CloudPosture"], + ) + report.subscription = subscription + report.resource_name = "Defender plan CSPM" + report.status = "PASS" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to ON (pricing tier standard)." + if pricings["CloudPosture"].pricing_tier != "Standard": + report.status = "FAIL" + report.status_extended = f"Defender plan CSPM from subscription {subscription_name} ({subscription}) is set to OFF (pricing tier not standard)." + + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/__init__.py b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json new file mode 100644 index 0000000000..c786fd49af --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_app_registration_credential_not_expired", + "CheckTitle": "App registration credentials are not expired or expiring soon", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** app registrations are evaluated for **credential validity**. Each app's password secrets and certificate credentials are checked for expiration. Credentials that are already expired, expiring within 30 days, or have no expiration date are flagged.", + "Risk": "Expired credentials cause **service outages** when apps can no longer authenticate. Credentials without expiration violate **least privilege** by persisting indefinitely. Long-lived or leaked secrets enable **unauthorized API access**, **data exfiltration**, and **lateral movement** via the app's permissions.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity-platform/howto-create-service-principal-portal", + "https://learn.microsoft.com/en-us/graph/api/resources/application" + ], + "Remediation": { + "Code": { + "CLI": "az ad app credential reset --id --years 1", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Identity > Applications > App registrations\n3. Select the application with the expiring credential\n4. Go to Certificates & secrets\n5. Delete the expired credential\n6. Add a new credential with an appropriate expiration (recommended: 6-12 months)\n7. Update the consuming application with the new credential\n8. Consider migrating to managed identities or federated credentials where possible", + "Terraform": "" + }, + "Recommendation": { + "Text": "Rotate app registration credentials before expiration. Set expiration to 6-12 months maximum. Prefer **managed identities** or **federated credentials** over secrets. Monitor credential expiry with Azure Monitor alerts or Microsoft Entra workbooks.", + "Url": "https://hub.prowler.com/check/entra_app_registration_credential_not_expired" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates both password secrets and certificate credentials. Each credential is reported individually. Apps with no credentials are skipped." +} diff --git a/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py new file mode 100644 index 0000000000..5cbdf009a0 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired.py @@ -0,0 +1,72 @@ +from datetime import datetime, timezone + +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.entra.entra_client import entra_client + +EXPIRY_WARNING_DAYS = 30 + + +class entra_app_registration_credential_not_expired(Check): + """ + Ensure Microsoft Entra ID app registration credentials are not expired or expiring soon. + + This check evaluates each app registration's password secrets and certificate credentials. A credential is reported individually and flagged when it is already expired, expiring within 30 days, or has no expiration date. Apps with no credentials are skipped. + + - PASS: The credential is valid for more than 30 days. + - FAIL: The credential is expired, expiring within 30 days, or has no expiration date. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, apps in entra_client.app_registrations.items(): + for app_id, app in apps.items(): + if not app.credentials: + continue + + for credential in app.credentials: + report = Check_Report_Azure(metadata=self.metadata(), resource=app) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = ( + f"{app.name} ({credential.credential_type}: " + f"{credential.display_name or 'unnamed'})" + ) + + if credential.end_date_time is None: + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential with no expiration date." + ) + else: + now = datetime.now(timezone.utc) + end = credential.end_date_time + if end.tzinfo is None: + end = end.replace(tzinfo=timezone.utc) + if end <= now: + days_ago = (now - end).days + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential that expired {days_ago} days ago." + ) + elif (end - now).days <= EXPIRY_WARNING_DAYS: + days_left = (end - now).days + report.status = "FAIL" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential expiring in {days_left} days (within the " + f"{EXPIRY_WARNING_DAYS}-day rotation threshold); rotate it soon." + ) + else: + days_left = (end - now).days + report.status = "PASS" + report.status_extended = ( + f"App '{app.name}' has a {credential.credential_type} " + f"credential valid for {days_left} more days (beyond the " + f"{EXPIRY_WARNING_DAYS}-day rotation threshold)." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/__init__.py b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json new file mode 100644 index 0000000000..11dfa6e85b --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "entra_authentication_methods_policy_strong_auth_enforced", + "CheckTitle": "Strong authentication methods are enabled with registration enforcement", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Microsoft Entra ID** authentication methods policy is evaluated for **strong authentication enforcement**. The check verifies that at least one strong method (Microsoft Authenticator, FIDO2, or X.509 Certificate) is enabled and that the MFA registration campaign is active to prompt users to enroll.", + "Risk": "Without strong authentication methods, the tenant relies on **passwords alone** or weak factors like SMS/voice. Password-only authentication enables **credential stuffing**, **phishing**, and **brute force** attacks. Without registration enforcement, users may never enroll in MFA even when it is available.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-methods-manage", + "https://learn.microsoft.com/en-us/entra/identity/authentication/how-to-nudge-authenticator-app" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Protection > Authentication methods > Policies\n3. Enable Microsoft Authenticator and/or FIDO2 security keys\n4. Go to Protection > Authentication methods > Registration campaign\n5. Set State to Enabled\n6. Configure included users/groups\n7. Consider disabling weak methods (SMS, Voice) after users migrate", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable **Microsoft Authenticator** and **FIDO2 security keys** as authentication methods. Activate the **MFA registration campaign** to prompt users to register. Disable weak methods (SMS, voice) after migration. Use **Conditional Access** to require strong authentication for all users.", + "Url": "https://hub.prowler.com/check/entra_authentication_methods_policy_strong_auth_enforced" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check reports a single finding per tenant and passes only when both the MFA registration campaign is enabled and at least one strong method is enabled. Microsoft recommends phishing-resistant methods (FIDO2, certificate-based) over app-based push notifications." +} diff --git a/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py new file mode 100644 index 0000000000..efdbcfa6a0 --- /dev/null +++ b/prowler/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced.py @@ -0,0 +1,63 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.entra.entra_client import entra_client + +# Methods considered strong (phishing-resistant or app-based MFA) +STRONG_METHODS = {"microsoftAuthenticator", "fido2", "x509Certificate"} + + +class entra_authentication_methods_policy_strong_auth_enforced(Check): + """ + Ensure the Entra ID authentication methods policy enforces strong authentication. + + This check evaluates the tenant authentication methods policy and reports a single finding per tenant. Strong authentication is considered enforced only when BOTH conditions hold: + 1. The MFA registration campaign is enabled (users are prompted to register methods). + 2. At least one strong, phishing-resistant or app-based method (Microsoft Authenticator, FIDO2, or X.509 certificate) is enabled. + + - PASS: Both conditions hold. + - FAIL: One or both conditions are missing; the status extended names exactly what is missing. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for tenant_domain, policy in entra_client.authentication_methods_policy.items(): + if policy is None: + continue + + report = Check_Report_Azure(metadata=self.metadata(), resource=policy) + report.subscription = f"Tenant: {tenant_domain}" + report.resource_name = "Authentication Methods Policy" + report.resource_id = policy.id + + registration_enabled = policy.registration_enforcement_state == "enabled" + enabled_strong = [ + config.method_name + for config in policy.method_configurations + if config.state == "enabled" and config.method_name in STRONG_METHODS + ] + + if registration_enabled and enabled_strong: + report.status = "PASS" + report.status_extended = ( + f"Strong authentication is enforced for tenant {tenant_domain}: " + f"the MFA registration campaign is enabled and strong methods are " + f"enabled ({', '.join(enabled_strong)})." + ) + else: + issues = [] + if not registration_enabled: + issues.append("the MFA registration campaign is not enabled") + if not enabled_strong: + issues.append( + "no strong authentication methods (Microsoft Authenticator, " + "FIDO2, or X.509 Certificate) are enabled" + ) + report.status = "FAIL" + report.status_extended = ( + f"Strong authentication is not enforced for tenant " + f"{tenant_domain}: {'; '.join(issues)}." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index eb1d62ac11..d77880b425 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -1,5 +1,6 @@ import asyncio from asyncio import gather +from datetime import datetime from typing import List, Optional from uuid import UUID @@ -49,6 +50,8 @@ class Entra(AzureService): self._get_named_locations(), self._get_directory_roles(), self._get_conditional_access_policy(), + self._get_app_registrations(), + self._get_authentication_methods_policy(), ) ) @@ -58,6 +61,8 @@ class Entra(AzureService): self.named_locations = attributes[3] self.directory_roles = attributes[4] self.conditional_access_policy = attributes[5] + self.app_registrations = attributes[6] + self.authentication_methods_policy = attributes[7] if created_loop: asyncio.set_event_loop(None) @@ -416,6 +421,135 @@ class Entra(AzureService): return conditional_access_policy + async def _get_app_registrations(self): + logger.info("Entra - Getting app registrations...") + app_registrations = {} + try: + for tenant, client in self.clients.items(): + app_registrations[tenant] = {} + apps_response = await client.applications.get() + + try: + while apps_response: + for app in getattr(apps_response, "value", []) or []: + credentials = [] + for cred in getattr(app, "password_credentials", []) or []: + credentials.append( + AppCredential( + display_name=getattr(cred, "display_name", "") + or "", + credential_type="password", + end_date_time=getattr( + cred, "end_date_time", None + ), + ) + ) + for cred in getattr(app, "key_credentials", []) or []: + credentials.append( + AppCredential( + display_name=getattr(cred, "display_name", "") + or "", + credential_type="certificate", + end_date_time=getattr( + cred, "end_date_time", None + ), + ) + ) + app_registrations[tenant][app.id] = AppRegistration( + id=app.id, + name=getattr(app, "display_name", "") or "", + credentials=credentials, + ) + + next_link = getattr(apps_response, "odata_next_link", None) + if not next_link: + break + apps_response = await client.applications.with_url( + next_link + ).get() + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return app_registrations + + async def _get_authentication_methods_policy(self): + logger.info("Entra - Getting authentication methods policy...") + auth_methods_policy = {} + try: + for tenant, client in self.clients.items(): + policy_response = ( + await client.policies.authentication_methods_policy.get() + ) + + if not policy_response: + auth_methods_policy[tenant] = None + continue + + # Parse registration enforcement + reg_enforcement = getattr( + policy_response, "registration_enforcement", None + ) + reg_campaign = ( + getattr( + reg_enforcement, + "authentication_methods_registration_campaign", + None, + ) + if reg_enforcement + else None + ) + registration_enforcement_state = ( + getattr(reg_campaign, "state", None) if reg_campaign else None + ) + + # Parse authentication method configurations + method_configs = [] + for config in ( + getattr( + policy_response, + "authentication_method_configurations", + [], + ) + or [] + ): + odata_type = getattr(config, "odata_type", "") or "" + # Extract method name from odata_type + # e.g. "#microsoft.graph.microsoftAuthenticatorAuthenticationMethodConfiguration" + method_name = ( + odata_type.split(".")[-1].replace( + "AuthenticationMethodConfiguration", "" + ) + if odata_type + else getattr(config, "id", "unknown") + ) + method_configs.append( + AuthMethodConfig( + id=getattr(config, "id", "") or "", + method_name=method_name, + state=getattr(config, "state", "disabled") or "disabled", + ) + ) + + auth_methods_policy[tenant] = AuthMethodsPolicy( + id=getattr(policy_response, "id", "") or "", + registration_enforcement_state=registration_enforcement_state, + method_configurations=method_configs, + ) + + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return auth_methods_policy + class User(BaseModel): id: str @@ -481,3 +615,27 @@ class ConditionalAccessPolicy(BaseModel): users: dict[str, List[str]] target_resources: dict[str, List[str]] access_controls: dict[str, List[str]] + + +class AppCredential(BaseModel): + display_name: str = "" + credential_type: str # "password" or "certificate" + end_date_time: Optional[datetime] = None + + +class AppRegistration(BaseModel): + id: str + name: str + credentials: List[AppCredential] = [] + + +class AuthMethodConfig(BaseModel): + id: str = "" + method_name: str + state: str = "disabled" # "enabled" or "disabled" + + +class AuthMethodsPolicy(BaseModel): + id: str = "" + registration_enforcement_state: Optional[str] = None # "enabled" or "disabled" + method_configurations: List[AuthMethodConfig] = [] diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..1dac541283 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "MySQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and recovery from a full regional outage.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for MySQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on MySQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure MySQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..5b1685354c --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure MySQL Flexible Server stores backups in a paired Azure region, enabling recovery from a full regional outage. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Geo-redundant backup is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"Geo-redundant backup is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..22a9b93401 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_high_availability_enabled", + "CheckTitle": "MySQL flexible server has high availability enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **high availability** mode. Zone-redundant or same-zone HA provisions a standby replica and provides automatic failover during planned and unplanned outages.", + "Risk": "Without **high availability**, a server or zone failure causes **downtime** until manual recovery or redeployment. Applications experience extended unavailability and potential data loss for in-flight transactions during unplanned outages.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-high-availability", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server update --name --resource-group --high-availability ZoneRedundant", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with zone-redundant high availability\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\n name: ''\n location: ''\n sku: {\n name: 'Standard_D2ds_v4'\n tier: 'GeneralPurpose'\n }\n properties: {\n highAvailability: {\n mode: 'ZoneRedundant' // CRITICAL: provisions a standby replica with automatic failover\n }\n }\n}\n```", + "Other": "1. In the Azure portal, open your Azure Database for MySQL flexible server\n2. Under Settings, select High availability\n3. Enable High availability and choose Zone redundant (or Same zone)\n4. Save (high availability requires the General Purpose or Business Critical tier)", + "Terraform": "```hcl\n# Terraform: MySQL Flexible Server with zone-redundant high availability\nresource \"azurerm_mysql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku_name = \"GP_Standard_D2ds_v4\"\n\n high_availability {\n mode = \"ZoneRedundant\" # CRITICAL: provisions a standby replica with automatic failover\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **high availability** (zone-redundant where supported, otherwise same-zone) on production MySQL Flexible Servers so a standby replica provides automatic failover. High availability requires the General Purpose or Business Critical tier; pair it with geo-redundant backup and periodic failover testing for full resilience.", + "Url": "https://hub.prowler.com/check/mysql_flexible_server_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "High availability for Azure MySQL Flexible Server requires the General Purpose or Business Critical compute tier and is not available on the Burstable tier." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py new file mode 100644 index 0000000000..e1f3054baf --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_high_availability_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have high availability enabled. + + This check evaluates whether each Azure MySQL Flexible Server is configured with high availability (zone-redundant or same-zone), providing automatic failover to a standby replica during outages. + + - PASS: The server has high availability enabled (high_availability_mode is set and not "Disabled"). + - FAIL: The server does not have high availability enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if ( + server.high_availability_mode is not None + and server.high_availability_mode != "Disabled" + ): + report.status = "PASS" + report.status_extended = f"High availability is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"High availability is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_service.py b/prowler/providers/azure/services/mysql/mysql_service.py index 565e14da01..b3a386a193 100644 --- a/prowler/providers/azure/services/mysql/mysql_service.py +++ b/prowler/providers/azure/services/mysql/mysql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.rdbms.mysql_flexibleservers import MySQLManagementClient @@ -21,6 +22,8 @@ class MySQL(AzureService): servers_list = client.servers.list() servers.update({subscription_id: {}}) for server in servers_list: + backup = getattr(server, "backup", None) + ha = getattr(server, "high_availability", None) servers[subscription_id].update( { server.id: FlexibleServer( @@ -31,6 +34,10 @@ class MySQL(AzureService): configurations=self._get_configurations( client, server.id.split("/")[4], server.name ), + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), + high_availability_mode=getattr(ha, "mode", None), ) } ) @@ -78,3 +85,5 @@ class FlexibleServer: location: str version: str configurations: dict[Configuration] + geo_redundant_backup: Optional[str] = None + high_availability_mode: Optional[str] = None diff --git a/prowler/providers/azure/services/network/network_service.py b/prowler/providers/azure/services/network/network_service.py index 0eb6144534..a924cf9609 100644 --- a/prowler/providers/azure/services/network/network_service.py +++ b/prowler/providers/azure/services/network/network_service.py @@ -17,6 +17,7 @@ class Network(AzureService): self.bastion_hosts = self._get_bastion_hosts() self.network_watchers = self._get_network_watchers() self.public_ip_addresses = self._get_public_ip_addresses() + self.virtual_networks = self._get_virtual_networks() def _get_security_groups(self): logger.info("Network - Getting Network Security Groups...") @@ -203,6 +204,41 @@ class Network(AzureService): ) return public_ip_addresses + def _get_virtual_networks(self): + logger.info("Network - Getting Virtual Networks...") + virtual_networks = {} + for subscription, client in self.clients.items(): + try: + virtual_networks[subscription] = [] + vnet_list = client.virtual_networks.list_all() + for vnet in vnet_list: + subnets = [] + for subnet in getattr(vnet, "subnets", []) or []: + nsg = getattr(subnet, "network_security_group", None) + subnets.append( + VNetSubnet( + id=subnet.id, + name=subnet.name, + nsg_id=getattr(nsg, "id", None) if nsg else None, + ) + ) + virtual_networks[subscription].append( + VirtualNetwork( + id=vnet.id, + name=vnet.name, + location=vnet.location, + enable_ddos_protection=getattr( + vnet, "enable_ddos_protection", False + ), + subnets=subnets, + ) + ) + except Exception as error: + logger.error( + f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return virtual_networks + @dataclass class BastionHost: @@ -261,3 +297,23 @@ class PublicIp: name: str location: str ip_address: str + + +@dataclass +class VNetSubnet: + id: str + name: str + nsg_id: Optional[str] = None + + +@dataclass +class VirtualNetwork: + id: str + name: str + location: str + enable_ddos_protection: bool = False + subnets: List[VNetSubnet] = None + + def __post_init__(self): + if self.subnets is None: + self.subnets = [] diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/__init__.py b/prowler/providers/azure/services/network/network_subnet_nsg_associated/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json new file mode 100644 index 0000000000..73cb41f886 --- /dev/null +++ b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "network_subnet_nsg_associated", + "CheckTitle": "Subnet has a network security group associated", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.network/virtualnetworks/subnets", + "ResourceGroup": "network", + "Description": "**Azure Virtual Network** subnets are evaluated for **Network Security Group (NSG)** association. Each subnet should have an NSG to enforce inbound and outbound traffic filtering rules. Subnets without NSGs allow all traffic by default.", + "Risk": "Subnets without NSGs have **no network-level access control**. All inbound and outbound traffic is allowed by default, enabling **lateral movement**, **unauthorized access** to resources, and **data exfiltration** across the virtual network.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview", + "https://learn.microsoft.com/en-us/azure/virtual-network/tutorial-filter-network-traffic" + ], + "Remediation": { + "Code": { + "CLI": "az network vnet subnet update --resource-group --vnet-name --name --network-security-group ", + "NativeIaC": "```bicep\nresource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' existing = {\n name: ''\n}\n\nresource subnet 'Microsoft.Network/virtualNetworks/subnets@2023-09-01' = {\n name: ''\n parent: vnet\n properties: {\n addressPrefix: '10.0.1.0/24'\n networkSecurityGroup: {\n id: '' // Critical: associates NSG with subnet\n }\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Go to Virtual networks and select the VNet\n3. Click on Subnets\n4. Select the subnet without an NSG\n5. Under Network security group, select an existing NSG or create a new one\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_subnet_network_security_group_association\" \"\" {\n subnet_id = \"\" # Critical: associates NSG with subnet\n network_security_group_id = \"\"\n}\n```" + }, + "Recommendation": { + "Text": "Associate a **Network Security Group** with every subnet. Create NSG rules following **least privilege** \u2014 deny all by default and allow only required traffic. Exclude Azure-managed subnets (GatewaySubnet, AzureFirewallSubnet, AzureBastionSubnet) which have their own security controls.", + "Url": "https://hub.prowler.com/check/network_subnet_nsg_associated" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check excludes Azure-managed subnets (GatewaySubnet, AzureFirewallSubnet, AzureFirewallManagementSubnet, AzureBastionSubnet, RouteServerSubnet) which should not have custom NSGs." +} diff --git a/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py new file mode 100644 index 0000000000..054f663351 --- /dev/null +++ b/prowler/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated.py @@ -0,0 +1,54 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.network.network_client import network_client + +# Subnets that are managed by Azure and should not have custom NSGs +EXCLUDED_SUBNET_NAMES = { + "GatewaySubnet", + "AzureFirewallSubnet", + "AzureFirewallManagementSubnet", + "AzureBastionSubnet", + "RouteServerSubnet", +} + + +class network_subnet_nsg_associated(Check): + """ + Ensure every subnet has a Network Security Group (NSG) associated. + + This check evaluates whether each subnet in every virtual network has an NSG associated to enforce inbound and outbound traffic filtering. Azure-managed subnets (e.g. GatewaySubnet, AzureFirewallSubnet, AzureBastionSubnet) are excluded because they must not have custom NSGs. + + - PASS: The subnet has an NSG associated. + - FAIL: The subnet does not have an NSG associated. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for subscription_name, vnets in network_client.virtual_networks.items(): + for vnet in vnets: + for subnet in vnet.subnets: + if subnet.name in EXCLUDED_SUBNET_NAMES: + continue + + report = Check_Report_Azure(metadata=self.metadata(), resource=vnet) + report.subscription = subscription_name + report.resource_name = f"{vnet.name}/{subnet.name}" + report.resource_id = subnet.id + report.location = vnet.location + + if subnet.nsg_id: + report.status = "PASS" + report.status_extended = ( + f"Subnet '{subnet.name}' in VNet '{vnet.name}' " + f"has an NSG associated." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Subnet '{subnet.name}' in VNet '{vnet.name}' " + f"does not have an NSG associated." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/__init__.py b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json new file mode 100644 index 0000000000..dfeef103c1 --- /dev/null +++ b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "azure", + "CheckID": "network_vnet_ddos_protection_enabled", + "CheckTitle": "Virtual network has Azure DDoS Network Protection enabled", + "CheckType": [], + "ServiceName": "network", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.network/virtualnetworks", + "ResourceGroup": "network", + "Description": "**Azure Virtual Networks** are evaluated for **DDoS Network Protection**. When enabled, Azure DDoS Protection provides enhanced mitigation against volumetric, protocol, and application-layer DDoS attacks targeting resources deployed in the virtual network.", + "Risk": "Without DDoS protection, Azure resources are only covered by **DDoS Infrastructure Protection** (basic), which may not mitigate sophisticated or large-scale attacks. Successful DDoS attacks cause **service unavailability** and potential **data exposure** during failover.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ddos-protection/ddos-protection-overview" + ], + "Remediation": { + "Code": { + "CLI": "az network ddos-protection create --resource-group --name && az network vnet update --resource-group --name --ddos-protection-plan ", + "NativeIaC": "```bicep\nresource vnet 'Microsoft.Network/virtualNetworks@2023-09-01' = {\n name: ''\n location: ''\n properties: {\n addressSpace: { addressPrefixes: ['10.0.0.0/16'] }\n enableDdosProtection: true // Critical: enables DDoS Protection\n ddosProtectionPlan: { id: '' }\n }\n}\n```", + "Other": "1. Sign in to Azure portal\n2. Search for 'DDoS protection plans'\n3. Create a new DDoS protection plan\n4. Go to Virtual networks and select the target VNet\n5. Under DDoS protection, select 'Enable' and select the plan\n6. Click Save", + "Terraform": "```hcl\nresource \"azurerm_virtual_network\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n address_space = [\"10.0.0.0/16\"]\n\n ddos_protection_plan {\n id = \"\" # Critical: associates DDoS Protection Plan\n enable = true\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **Azure DDoS Network Protection** on virtual networks hosting internet-facing workloads. Consider cost implications — DDoS Network Protection has a monthly charge per plan. Use **DDoS IP Protection** as a lower-cost alternative for individual public IPs.", + "Url": "https://hub.prowler.com/check/network_vnet_ddos_protection_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Azure DDoS Network Protection has a monthly cost (~$2,944/month per plan). Consider whether the workload justifies this cost. Internal-only VNets without internet-facing resources may not need DDoS protection." +} diff --git a/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py new file mode 100644 index 0000000000..6a9a081fb7 --- /dev/null +++ b/prowler/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.network.network_client import network_client + + +class network_vnet_ddos_protection_enabled(Check): + """ + Ensure Azure DDoS Network Protection is enabled on virtual networks. + + This check evaluates whether each virtual network has a DDoS protection plan associated, providing always-on traffic monitoring and adaptive mitigation against volumetric and protocol attacks. + + - PASS: The virtual network has DDoS protection enabled. + - FAIL: The virtual network does not have DDoS protection enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + + for subscription_name, vnets in network_client.virtual_networks.items(): + for vnet in vnets: + report = Check_Report_Azure(metadata=self.metadata(), resource=vnet) + report.subscription = subscription_name + report.resource_name = vnet.name + report.resource_id = vnet.id + report.location = vnet.location + + if vnet.enable_ddos_protection: + report.status = "PASS" + report.status_extended = ( + f"Virtual network '{vnet.name}' has DDoS " + f"protection enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Virtual network '{vnet.name}' does not have " + f"DDoS protection enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..7be125ecf2 --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "postgresql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "PostgreSQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "postgresql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbforpostgresql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure PostgreSQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and cross-region disaster recovery.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbforpostgresql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az postgres flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: PostgreSQL Flexible Server with geo-redundant backup (set at creation)\nresource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2023-06-01-preview' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for PostgreSQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: PostgreSQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_postgresql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on PostgreSQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/postgresql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure PostgreSQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..94fdaa7a1c --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.postgresql.postgresql_client import ( + postgresql_client, +) + + +class postgresql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure PostgreSQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure PostgreSQL Flexible Server stores backups in a paired Azure region, enabling cross-region disaster recovery. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for ( + subscription, + flexible_servers, + ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) + for server in flexible_servers: + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has geo-redundant backup enabled." + else: + report.status = "FAIL" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) does not have geo-redundant backup enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/__init__.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..58b6588886 --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "postgresql_flexible_server_high_availability_enabled", + "CheckTitle": "PostgreSQL flexible server has high availability enabled", + "CheckType": [], + "ServiceName": "postgresql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbforpostgresql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure PostgreSQL Flexible Server** is evaluated for **high availability** mode. Zone-redundant or same-zone HA provisions a standby replica and provides automatic failover during planned and unplanned outages.", + "Risk": "Without **high availability**, a server or zone failure causes **downtime** until manual recovery or redeployment. Critical databases experience extended unavailability and potential data loss for in-flight transactions during unplanned outages.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-high-availability", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbforpostgresql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az postgres flexible-server update --name --resource-group --high-availability ZoneRedundant", + "NativeIaC": "```bicep\n// Bicep: PostgreSQL Flexible Server with zone-redundant high availability\nresource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2023-06-01-preview' = {\n name: ''\n location: ''\n sku: {\n name: 'Standard_D2ds_v4'\n tier: 'GeneralPurpose'\n }\n properties: {\n highAvailability: {\n mode: 'ZoneRedundant' // CRITICAL: provisions a standby replica with automatic failover\n }\n }\n}\n```", + "Other": "1. In the Azure portal, open your Azure Database for PostgreSQL flexible server\n2. Under Settings, select High availability\n3. Enable High availability and choose Zone redundant (or Same zone)\n4. Save (high availability requires the General Purpose or Memory Optimized tier)", + "Terraform": "```hcl\n# Terraform: PostgreSQL Flexible Server with zone-redundant high availability\nresource \"azurerm_postgresql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n sku_name = \"GP_Standard_D2ds_v4\"\n\n high_availability {\n mode = \"ZoneRedundant\" # CRITICAL: provisions a standby replica with automatic failover\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable **high availability** (zone-redundant where supported, otherwise same-zone) on production PostgreSQL Flexible Servers so a standby replica provides automatic failover. High availability requires the General Purpose or Memory Optimized tier; pair it with geo-redundant backup and periodic failover testing for full resilience.", + "Url": "https://hub.prowler.com/check/postgresql_flexible_server_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "High availability for Azure PostgreSQL Flexible Server requires the General Purpose or Memory Optimized compute tier and is not available on the Burstable tier." +} diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py new file mode 100644 index 0000000000..14c69f591e --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.postgresql.postgresql_client import ( + postgresql_client, +) + + +class postgresql_flexible_server_high_availability_enabled(Check): + """ + Ensure Azure PostgreSQL Flexible Servers have high availability enabled. + + This check evaluates whether each Azure PostgreSQL Flexible Server is configured with high availability (zone-redundant or same-zone), providing automatic failover to a standby replica during outages. + + - PASS: The server has high availability enabled (high_availability_mode is set and not "Disabled"). + - FAIL: The server does not have high availability enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for ( + subscription, + flexible_servers, + ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) + for server in flexible_servers: + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription + if ( + server.high_availability_mode is not None + and server.high_availability_mode != "Disabled" + ): + report.status = "PASS" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has high availability enabled." + else: + report.status = "FAIL" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) does not have high availability enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_service.py b/prowler/providers/azure/services/postgresql/postgresql_service.py index 13081ad270..2048299bf1 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_service.py +++ b/prowler/providers/azure/services/postgresql/postgresql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient @@ -53,6 +54,8 @@ class PostgreSQL(AzureService): subscription, resource_group, postgresql_server.name ) location = server_details.location + backup = getattr(server_details, "backup", None) + ha = getattr(server_details, "high_availability", None) flexible_servers[subscription].append( Server( id=postgresql_server.id, @@ -68,6 +71,10 @@ class PostgreSQL(AzureService): connection_throttling=connection_throttling, log_retention_days=log_retention_days, firewall=firewall, + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), + high_availability_mode=getattr(ha, "mode", None), ) ) except Exception as error: @@ -149,15 +156,37 @@ class PostgreSQL(AzureService): ) return admin_list except Exception as e: - logger.error(f"Error getting Entra ID admins for {server_name}: {e}") + if "authentication is not enabled" in str(e): + # Expected when the server uses PostgreSQL authentication only + # (Entra/Azure AD auth disabled); not an error. + logger.warning( + f"Entra ID authentication is not enabled for {server_name}; skipping Entra ID admins." + ) + else: + logger.error(f"Error getting Entra ID admins for {server_name}: {e}") return [] def _get_connection_throttling(self, subscription, resouce_group_name, server_name): client = self.clients[subscription] - connection_throttling = client.configurations.get( - resouce_group_name, server_name, "connection_throttle.enable" - ) - return connection_throttling.value.upper() + try: + connection_throttling = client.configurations.get( + resouce_group_name, server_name, "connection_throttle.enable" + ) + return connection_throttling.value.upper() + except Exception as error: + message = str(error).lower() + if "connection_throttle.enable" in message and ( + "not exist" in message or "not found" in message + ): + # The "connection_throttle.enable" parameter does not exist on + # newer PostgreSQL versions (e.g. v18); this is expected. + return None + # Any other failure is a genuine problem: surface it, but still + # degrade gracefully instead of aborting the subscription inventory. + logger.error( + f"Error getting connection throttling for {server_name}: {error}" + ) + return None def _get_log_retention_days(self, subscription, resouce_group_name, server_name): client = self.clients[subscription] @@ -214,6 +243,8 @@ class Server: log_checkpoints: str log_connections: str log_disconnections: str - connection_throttling: str - log_retention_days: str + connection_throttling: Optional[str] + log_retention_days: Optional[str] firewall: list[Firewall] + geo_redundant_backup: Optional[str] = None + high_availability_mode: Optional[str] = None diff --git a/prowler/providers/azure/services/recovery/__init__.py b/prowler/providers/azure/services/recovery/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/__init__.py b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json new file mode 100644 index 0000000000..33f8516d1b --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "recovery_vault_backup_policy_retention_adequate", + "CheckTitle": "Recovery Services backup policy has at least 30 days retention", + "CheckType": [], + "ServiceName": "recovery", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "**Azure Recovery Services** backup policies are evaluated for adequate **daily retention**. Policies with fewer than 30 days of retention may not provide sufficient recovery points for incident investigation, compliance requirements, or ransomware recovery scenarios.", + "Risk": "Short retention periods limit the ability to recover from **delayed-discovery incidents** such as ransomware that encrypts backups over time, data corruption that propagates across backup cycles, or compliance investigations requiring historical data. Insufficient retention may violate regulatory requirements.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-vm-backup-faq", + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-backup-faq" + ], + "Remediation": { + "Code": { + "CLI": "az backup policy set --resource-group --vault-name --name --policy @policy.json", + "NativeIaC": "", + "Other": "1. Sign in to the Azure portal\n2. Go to Recovery Services vaults and select the vault\n3. Go to Backup policies\n4. Select the policy to modify\n5. Under Retention range, set Daily backup point retention to at least 30 days\n6. Click Save", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set daily backup retention to at least **30 days**. For production workloads, consider 90+ days. Configure **weekly, monthly, and yearly** retention points for long-term compliance. Use **immutable vaults** to prevent backup deletion by compromised admin accounts.", + "Url": "https://hub.prowler.com/check/recovery_vault_backup_policy_retention_adequate" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check evaluates daily retention from the backup policy's retention_policy.daily_schedule.retention_duration.count. Policies without a daily schedule are flagged. The 30-day minimum aligns with common compliance requirements and incident response timelines." +} diff --git a/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py new file mode 100644 index 0000000000..b770f87a62 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate.py @@ -0,0 +1,65 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.recovery.recovery_client import recovery_client + +MINIMUM_RETENTION_DAYS = 30 + + +class recovery_vault_backup_policy_retention_adequate(Check): + """Check if vault backup policies retain backups for at least 30 days.""" + + def execute(self) -> list[Check_Report_Azure]: + """Execute the check across Recovery Services vault backup policies.""" + + findings = [] + + for subscription_name, vaults in recovery_client.vaults.items(): + for vault in vaults.values(): + if not vault.backup_policies: + report = Check_Report_Azure( + metadata=self.metadata(), resource=vault + ) + report.subscription = subscription_name + report.resource_name = vault.name + report.resource_id = vault.id + report.location = vault.location + report.status = "FAIL" + report.status_extended = ( + f"Recovery vault '{vault.name}' has no backup " + f"policies configured." + ) + findings.append(report) + continue + + for policy in vault.backup_policies.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=vault + ) + report.subscription = subscription_name + report.resource_name = f"{vault.name}/{policy.name}" + report.resource_id = policy.id + report.location = vault.location + + if policy.retention_days is None: + report.status = "FAIL" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has no daily retention configured." + ) + elif policy.retention_days < MINIMUM_RETENTION_DAYS: + report.status = "FAIL" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has {policy.retention_days}-day " + f"retention (minimum: {MINIMUM_RETENTION_DAYS})." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Backup policy '{policy.name}' in vault " + f"'{vault.name}' has {policy.retention_days}-day " + f"retention." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/__init__.py b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json new file mode 100644 index 0000000000..04e4405851 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "recovery_vault_has_protected_items", + "CheckTitle": "Recovery Services vault has backup protected items configured", + "CheckType": [], + "ServiceName": "recovery", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.recoveryservices/vaults", + "ResourceGroup": "storage", + "Description": "**Azure Recovery Services** vaults are evaluated for **backup protected items**. A vault with no protected items indicates that no resources (VMs, databases, file shares) are configured for backup through this vault, potentially leaving critical workloads unprotected.", + "Risk": "Empty vaults represent a gap in **disaster recovery** coverage. Resources without backup protection face permanent data loss from accidental deletion, ransomware, corruption, or regional outages. The vault may have been provisioned but backup was never configured.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/backup/backup-azure-vms-first-look-arm", + "https://learn.microsoft.com/en-us/azure/backup/backup-overview" + ], + "Remediation": { + "Code": { + "CLI": "az backup protection enable-for-vm --resource-group --vault-name --vm --policy-name DefaultPolicy", + "NativeIaC": "", + "Other": "1. Sign in to the Azure portal\n2. Go to Recovery Services vaults and select the vault\n3. Click + Backup\n4. Select the workload type (Azure VM, SQL, File Share, etc.)\n5. Select the backup policy\n6. Choose the resources to protect\n7. Click Enable Backup", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure **backup protection** for critical resources including VMs, databases, and file shares. Use **Azure Backup policies** with appropriate retention and frequency. If the vault is unused, consider deleting it to reduce resource sprawl.", + "Url": "https://hub.prowler.com/check/recovery_vault_has_protected_items" + } + }, + "Categories": [ + "forensics-ready" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check reports on each Recovery Services vault individually. An empty vault is not necessarily a security risk if backups are managed through a different vault or Azure Backup center." +} diff --git a/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py new file mode 100644 index 0000000000..6ebd9e5328 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.recovery.recovery_client import recovery_client + + +class recovery_vault_has_protected_items(Check): + """Check if Recovery Services vaults have protected backup items.""" + + def execute(self) -> list[Check_Report_Azure]: + """Execute the check across Recovery Services vaults.""" + + findings = [] + + for subscription_name, vaults in recovery_client.vaults.items(): + for vault in vaults.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=vault) + report.subscription = subscription_name + report.resource_name = vault.name + report.resource_id = vault.id + report.location = vault.location + + if vault.backup_protected_items: + report.status = "PASS" + report.status_extended = ( + f"Recovery Services vault '{vault.name}' has " + f"{len(vault.backup_protected_items)} protected items." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Recovery Services vault '{vault.name}' has no " + f"protected items configured." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/common/arguments.py b/prowler/providers/common/arguments.py index 9e23274a90..2e4d9c8896 100644 --- a/prowler/providers/common/arguments.py +++ b/prowler/providers/common/arguments.py @@ -10,16 +10,43 @@ provider_arguments_lib_path = "lib.arguments.arguments" validate_provider_arguments_function = "validate_arguments" init_provider_arguments_function = "init_parser" +# Kept in sync with parser.py's argv normalisation; both consumers import this. +PROVIDER_ALIASES = { + "microsoft365": "m365", + "oci": "oraclecloud", +} + + +def _invoked_provider_from_argv(available_providers: Sequence[str]) -> Optional[str]: + """Return the provider name the user invoked, or None. + + Mirrors `ProwlerArgumentParser.parse()` resolution: only inspects + `sys.argv[1]`. Scanning the whole argv would misclassify + `prowler --output-directory stackit` as `stackit`. + """ + available = set(available_providers) + if len(sys.argv) < 2: + return "aws" if "aws" in available else None + first = sys.argv[1] + if first in ("-h", "--help", "-v", "--version"): + return None + if first.startswith("-"): + return "aws" if "aws" in available else None + normalized = PROVIDER_ALIASES.get(first, first) + return normalized if normalized in available else None + def init_providers_parser(self): - """init_providers_parser calls the provider init_parser function to load all the arguments and flags. Receives a ProwlerArgumentParser object""" - # We need to call the arguments parser for each provider + """Build the subparser of each available provider. + + Built-in load failures are captured silently on + `self._builtin_load_failures`; the warn/exit decision is deferred to + `enforce_invoked_provider_loaded()` because `parse(args=...)` can + override `sys.argv` after this function ran. + """ + self._builtin_load_failures = {} providers = Provider.get_available_providers() for provider in providers: - # Discriminate built-in vs external upfront via find_spec, so an - # ImportError from a transitive dependency missing inside a built-in - # arguments module surfaces clearly instead of being silently - # re-routed to the entry-point path (which only has external providers). if Provider.is_builtin(provider): try: getattr( @@ -28,21 +55,9 @@ def init_providers_parser(self): ), init_provider_arguments_function, )(self) - except ImportError as e: - logger.critical( - f"Failed to load arguments for built-in provider '{provider}'. " - f"Missing dependency: {e}. " - f"Ensure all required dependencies are installed." - ) - logger.debug("Full traceback:", exc_info=True) - sys.exit(1) except Exception as error: - logger.critical( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - sys.exit(1) + self._builtin_load_failures[provider] = error else: - # External provider — init_parser classmethod via entry point cls = Provider._load_ep_provider(provider) if cls and hasattr(cls, "init_parser"): try: @@ -53,6 +68,51 @@ def init_providers_parser(self): ) +def enforce_invoked_provider_loaded(self): + """Apply selective fail-loud over the failures captured at init time. + + Called by `ProwlerArgumentParser.parse()` AFTER argv normalisation so + the invoked provider matches what argparse will dispatch to — including + the case where `parse(args=...)` overrode the ambient `sys.argv`. + + Invoked + failed → critical + `sys.exit(1)`. Others → warning. + """ + failures = getattr(self, "_builtin_load_failures", {}) + if not failures: + return + invoked = _invoked_provider_from_argv(Provider.get_available_providers()) + for provider, error in failures.items(): + if provider == invoked: + continue + if isinstance(error, ImportError): + logger.warning( + f"Skipping built-in provider '{provider}' due to missing " + f"dependency: {error}. It will be unavailable in this " + f"invocation, but the CLI continues because you invoked a " + f"different provider." + ) + else: + logger.warning( + f"Skipping built-in provider '{provider}': " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if invoked is None or invoked not in failures: + return + error = failures[invoked] + if isinstance(error, ImportError): + logger.critical( + f"Failed to load arguments for built-in provider '{invoked}'. " + f"Missing dependency: {error}. " + f"Ensure all required dependencies are installed." + ) + logger.debug("Full traceback:", exc_info=True) + else: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + sys.exit(1) + + def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]: """validate_provider_arguments returns {True, "} if the provider arguments passed are valid and can be used together""" try: diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 8bc7567795..9c314b3233 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -261,36 +261,16 @@ class Provider(ABC): @staticmethod def init_global_provider(arguments: Namespace) -> None: try: - # Discriminate built-in vs external upfront via find_spec, so an - # ImportError from a transitive dependency missing inside a - # built-in's own import chain surfaces clearly instead of being - # silently re-routed to the entry-point path. - provider_class = None - if Provider.is_builtin(arguments.provider): - # Built-in wins on provider-name collision. Plug-ins are - # first-class extenders (they can register new provider - # names) but cannot override existing built-ins — a security - # tool prefers fail-loud predictability over silent - # overrides. Surface the override so the user knows their - # plug-in is being ignored and can rename it. - # Match by name only — never ep.load() a shadowing plug-in. - if any( - ep.name == arguments.provider - for ep in importlib.metadata.entry_points(group="prowler.providers") - ): - logger.warning( - f"Plug-in provider '{arguments.provider}' registered " - f"via entry points is being IGNORED — a built-in with " - f"the same name exists. To use your plug-in, register " - f"it under a different name." - ) - provider_class_path = f"{providers_path}.{arguments.provider}.{arguments.provider}_provider" - provider_class_name = f"{arguments.provider.capitalize()}Provider" - try: - provider_class = getattr( - import_module(provider_class_path), provider_class_name - ) - except ImportError as e: + # Delegate class resolution to the public, side-effect-free + # resolver. init_global_provider owns the CLI-specific error + # handling: a missing transitive dep in a built-in becomes a + # logger.critical + sys.exit(1); a completely unknown provider + # re-raises so the outer try/except can sys.exit too. + try: + provider_class = Provider.get_class(arguments.provider) + except ImportError as e: + if Provider.is_builtin(arguments.provider): + # Built-in's transitive dependency is missing — loud CLI error. logger.critical( f"Failed to load built-in provider '{arguments.provider}'. " f"Missing dependency: {e}. " @@ -298,25 +278,28 @@ class Provider(ABC): ) logger.debug("Full traceback:", exc_info=True) sys.exit(1) - except AttributeError: - # Module exists but doesn't define the expected class — - # treat as external and try entry points. - provider_class = Provider._load_ep_provider(arguments.provider) - else: - provider_class = Provider._load_ep_provider(arguments.provider) + # Unknown or missing external provider — propagate so the + # outer try/except can handle it (sys.exit(1) via generic + # exception handler). + raise - if provider_class is None: - raise ImportError( - f"Provider '{arguments.provider}' not found as built-in or entry point" + # Built-in wins on name collision — warn that a same-named + # plug-in is ignored. This lives here (not in get_class) so + # that `prowler --help` and API callers that resolve a class + # without initialising a global provider do not see spurious + # warnings. Match by name only — never ep.load() a shadowing + # plug-in, or its module code would run during a built-in run. + if Provider.is_builtin(arguments.provider) and any( + ep.name == arguments.provider + for ep in importlib.metadata.entry_points(group="prowler.providers") + ): + logger.warning( + f"Plug-in provider '{arguments.provider}' registered " + f"via entry points is being IGNORED — a built-in with " + f"the same name exists. To use your plug-in, register " + f"it under a different name." ) - # Kept for downstream forks that may extend the dispatch below - # with their own custom built-in branches and reference this name. - # The upstream chain dispatches by `arguments.provider` directly. - provider_class_name = ( - f"{arguments.provider.capitalize()}Provider" # noqa: F841 - ) - fixer_config = load_and_validate_config_file( arguments.provider, arguments.fixer_config ) @@ -603,6 +586,16 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, fixer_config=fixer_config, ) + elif arguments.provider == "linode": + # Credentials are read from the LINODE_TOKEN env var by the + # provider itself; there are no credential CLI flags to + # avoid leaking secrets. + provider_class( + config_path=arguments.config_file, + mutelist_path=arguments.mutelist_file, + fixer_config=fixer_config, + regions=getattr(arguments, "region", None), + ) else: # Dynamic fallback: any external/custom provider. # Honor the from_cli_args type hint (-> Provider): if the @@ -693,30 +686,69 @@ class Provider(ABC): Provider._ep_providers[name] = None return None + @staticmethod + def get_class(provider: str) -> type: + """Resolve the provider class for a name (built-in or entry-point). + + Does not call ``sys.exit`` and does not initialize the global + provider (it may populate the ``_ep_providers`` memoization cache). + Collision warnings are emitted by ``init_global_provider``, not here. + The caller handles errors (CLI exits; the API can return HTTP 400). + + Args: + provider: Provider name, e.g. ``"aws"`` or an external plug-in. + + Returns: + The provider class (a subclass of :class:`Provider`). + + Raises: + ImportError: If not found as built-in or entry point, a built-in's + transitive dependency is missing, or an entry point resolves to + an object that is not a subclass of :class:`Provider`. + """ + if Provider.is_builtin(provider): + provider_class_path = f"{providers_path}.{provider}.{provider}_provider" + provider_class_name = f"{provider.capitalize()}Provider" + # Let ImportError propagate — the caller decides whether to + # sys.exit (CLI) or return HTTP 400 (API). + module = import_module(provider_class_path) + try: + return getattr(module, provider_class_name) + except AttributeError as error: + # is_builtin already confirmed this is a built-in, so the + # module MUST define the expected class. A missing class is a + # broken built-in contract — raise rather than fall back to a + # same-named external plug-in, which would contradict + # is_builtin and silently return a foreign class. + raise ImportError( + f"Built-in provider '{provider}' module " + f"'{provider_class_path}' does not define expected class " + f"'{provider_class_name}'" + ) from error + + cls = Provider._load_ep_provider(provider) + if cls is None: + raise ImportError( + f"Provider '{provider}' not found as built-in or entry point" + ) + # ep.load() can return any object; enforce the public contract that + # get_class returns a Provider subclass. isinstance(cls, type) guards + # issubclass against a TypeError when cls is not a class at all. + if not (isinstance(cls, type) and issubclass(cls, Provider)): + raise ImportError( + f"Entry-point provider '{provider}' resolved to {cls!r}, " + f"which is not a subclass of Provider" + ) + return cls + @staticmethod def get_providers_help_text() -> dict: """Returns a dict of {provider_name: cli_help_text} for all available providers.""" help_text = {} for name in Provider.get_available_providers(): try: - # Try built-in first - module_path = f"{providers_path}.{name}.{name}_provider" - module = import_module(module_path) - cls = None - for attr_name in dir(module): - attr = getattr(module, attr_name) - if ( - isinstance(attr, type) - and issubclass(attr, Provider) - and attr is not Provider - ): - cls = attr - break - help_text[name] = getattr(cls, "_cli_help_text", "") if cls else "" - except ImportError: - # External provider — load via entry point - cls = Provider._load_ep_provider(name) - help_text[name] = getattr(cls, "_cli_help_text", "") if cls else "" + cls = Provider.get_class(name) + help_text[name] = getattr(cls, "_cli_help_text", "") except Exception as error: logger.warning( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/providers/gcp/exceptions/exceptions.py b/prowler/providers/gcp/exceptions/exceptions.py index 5c5845951c..09cb642cba 100644 --- a/prowler/providers/gcp/exceptions/exceptions.py +++ b/prowler/providers/gcp/exceptions/exceptions.py @@ -34,11 +34,17 @@ class GCPBaseException(ProwlerException): "message": "Error loading Service Account Private Key credentials from dictionary", "remediation": "Check the dictionary and ensure it contains a Service Account Private Key.", }, + (3011, "GCPGetOrganizationProjectsError"): { + "message": "Error retrieving projects under the organization via the Cloud Asset API", + "remediation": "Ensure the Cloud Asset API is enabled in the credentials' project and that the principal has 'roles/cloudasset.viewer' bound at the organization level. See https://cloud.google.com/asset-inventory/docs/access-control.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): provider = "GCP" - error_info = self.GCP_ERROR_CODES.get((code, self.__class__.__name__)) + # Copy the catalog entry so a custom message does not mutate the + # class-level GCP_ERROR_CODES shared across exception instances. + error_info = dict(self.GCP_ERROR_CODES.get((code, self.__class__.__name__))) if message: error_info["message"] = message super().__init__( @@ -104,3 +110,10 @@ class GCPLoadServiceAccountKeyFromDictError(GCPCredentialsError): super().__init__( 3010, file=file, original_exception=original_exception, message=message ) + + +class GCPGetOrganizationProjectsError(GCPBaseException): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 3011, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index 5017b84c42..39b3392fdf 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -21,6 +21,8 @@ from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS from prowler.providers.gcp.exceptions.exceptions import ( + GCPBaseException, + GCPGetOrganizationProjectsError, GCPInvalidProviderIdError, GCPLoadADCFromDictError, GCPLoadServiceAccountKeyFromDictError, @@ -621,10 +623,7 @@ class GcpProvider(Provider): credentials_file: str Returns: - dict[str, GCPProject] - - Usage: - >>> GcpProvider.get_projects(credentials=credentials, organization_id=organization_id) + dict of project_id and GCPProject object """ projects = {} try: @@ -632,7 +631,10 @@ class GcpProvider(Provider): try: # Initialize Cloud Asset Inventory API for recursive project retrieval asset_service = discovery.build( - "cloudasset", "v1", credentials=credentials + "cloudasset", + "v1", + credentials=credentials, + num_retries=DEFAULT_RETRY_ATTEMPTS, ) # Set the scope to the specified organization and filter for projects scope = f"organizations/{organization_id}" @@ -643,7 +645,7 @@ class GcpProvider(Provider): ) while request is not None: - response = request.execute() + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) for asset in response.get("assets", []): # Extract labels and other project details @@ -688,13 +690,25 @@ class GcpProvider(Provider): ) except HttpError as http_error: if "Cloud Asset API has not been used" in str(http_error): - logger.error( - f"Projects cannot be retrieved from the Organization since Cloud Asset API has not been used before or it is disabled [{http_error.__traceback__.tb_lineno}]. Enable it by visiting https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry." + message = ( + "Projects cannot be retrieved from the Organization since the Cloud Asset API " + "has not been used before or it is disabled. Enable it by visiting " + "https://console.developers.google.com/apis/api/cloudasset.googleapis.com/ then retry." ) else: - logger.error( - f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}" + message = ( + f"Cloud Asset API call failed while listing projects under organization " + f"'{organization_id}': {http_error}. Ensure the credentials' principal has " + "'roles/cloudasset.viewer' bound at the organization level." ) + logger.critical( + f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {message}" + ) + raise GCPGetOrganizationProjectsError( + file=__file__, + original_exception=http_error, + message=message, + ) else: try: # Initialize Cloud Resource Manager API for simple project listing @@ -781,8 +795,10 @@ class GcpProvider(Provider): labels={}, lifecycle_state="ACTIVE", ) - # If no projects were able to be accessed via API, add them manually from the credentials file - elif credentials_file: + # If no projects were able to be accessed via API, add them manually from the credentials file. + # Skip this fallback when an organization scan was explicitly requested: silently + # downgrading scope to the service account's home project hides permission errors. + elif credentials_file and not organization_id: with open(credentials_file, "r", encoding="utf-8") as file: project_id = json.load(file)["project_id"] # Handle empty or null project names @@ -798,6 +814,8 @@ class GcpProvider(Provider): labels={}, lifecycle_state="ACTIVE", ) + except GCPBaseException as gcp_error: + raise gcp_error except Exception as error: logger.critical( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" diff --git a/prowler/providers/gcp/services/cloudfunction/__init__.py b/prowler/providers/gcp/services/cloudfunction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py new file mode 100644 index 0000000000..a252da1be7 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) + +cloudfunction_client = CloudFunction(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json new file mode 100644 index 0000000000..72c908bf80 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "cloudfunction_function_inside_vpc", + "CheckTitle": "Cloud Function is connected to a VPC network", + "CheckType": [], + "ServiceName": "cloudfunction", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "cloudfunctions.googleapis.com/Function", + "Description": "Cloud Functions are attached to a **Serverless VPC Access connector** so egress traffic is routed through a private VPC network instead of the public internet.\n\nThe evaluation reviews each function's network configuration to confirm that a connector is configured.", + "Risk": "Without a VPC connector, Cloud Functions cannot privately reach internal resources such as `Cloud SQL`, `Memorystore`, or `GKE`, forcing those services to be exposed over public IPs. This expands the **attack surface**, weakens **confidentiality** of internal traffic, and breaks network segmentation controls required by most security frameworks.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/functions/docs/networking/connecting-vpc", + "https://cloud.google.com/vpc/docs/serverless-vpc-access" + ], + "Remediation": { + "Code": { + "CLI": "gcloud functions deploy --region= --vpc-connector=projects//locations//connectors/ --egress-settings=all-traffic", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Cloud Functions\n2. Select the function and click Edit\n3. Under Connections, select the VPC connector for your network\n4. Set Egress settings to route all traffic through the VPC connector\n5. Save and redeploy the function", + "Terraform": "```hcl\nresource \"google_cloudfunctions2_function\" \"\" {\n name = \"\"\n location = \"us-central1\"\n\n service_config {\n vpc_connector = \"\" # Critical: routes egress through the VPC\n vpc_connector_egress_settings = \"ALL_TRAFFIC\"\n }\n}\n```" + }, + "Recommendation": { + "Text": "Apply **defense in depth** by routing Cloud Function egress through a **Serverless VPC Access connector** when the function must reach internal resources.\n\nScope each connector to **least privilege** subnets so functions cannot reach unintended endpoints.", + "Url": "https://hub.prowler.com/check/cloudfunction_function_inside_vpc" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfunction_function_not_publicly_accessible", + "cloudsql_instance_public_ip", + "compute_instance_public_ip" + ], + "Notes": "A VPC connector must be created in the same region as the Cloud Function. This check only verifies that a connector is attached; it does not validate egress settings or connector configuration." +} diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py new file mode 100644 index 0000000000..3b28db7d8e --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py @@ -0,0 +1,43 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudfunction.cloudfunction_client import ( + cloudfunction_client, +) + + +class cloudfunction_function_inside_vpc(Check): + """Check that Cloud Functions are attached to a Serverless VPC Access connector. + + Verifies that each active Cloud Function has a `vpcConnector` configured so + egress traffic flows through a private VPC network instead of the public + internet. Functions in non-`ACTIVE` states are skipped because their network + configuration is transient. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the VPC-connector check across all Cloud Functions. + + Returns: + A list of `Check_Report_GCP` findings, one per active Cloud + Function. Status is `PASS` when a `vpc_connector` is set and `FAIL` + otherwise. + """ + findings = [] + for function in cloudfunction_client.functions: + if function.state != "ACTIVE": + continue + report = Check_Report_GCP( + metadata=self.metadata(), + resource=function, + resource_id=function.name, + ) + if function.vpc_connector: + report.status = "PASS" + report.status_extended = ( + f"Cloud Function {function.name} is connected to a VPC via " + f"connector: {function.vpc_connector}." + ) + else: + report.status = "FAIL" + report.status_extended = f"Cloud Function {function.name} is not connected to any VPC network." + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json new file mode 100644 index 0000000000..8c9247b2bd --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "cloudfunction_function_not_publicly_accessible", + "CheckTitle": "Cloud Function is not publicly invocable", + "CheckType": [], + "ServiceName": "cloudfunction", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "cloudfunctions.googleapis.com/Function", + "Description": "Cloud Functions deny invocation to `allUsers` and `allAuthenticatedUsers`, so only **explicitly authorized identities or services** can trigger them.\n\nThe evaluation reviews each function's IAM policy bindings to confirm no public principals are granted invoker access.", + "Risk": "Publicly invocable Cloud Functions expose **business logic** to the internet and let any caller trigger execution. This enables **unauthorized data access** when the function returns sensitive output, **code execution** in shared environments, and **denial-of-wallet** attacks driven by uncontrolled invocation costs.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/functions/docs/securing/authenticating", + "https://cloud.google.com/iam/docs/overview" + ], + "Remediation": { + "Code": { + "CLI": "gcloud functions remove-iam-policy-binding --region= --member= --role=roles/cloudfunctions.invoker", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Cloud Functions\n2. Select the function and open the Permissions tab\n3. Remove any binding with allUsers or allAuthenticatedUsers\n4. Grant invocation rights only to specific service accounts or user groups", + "Terraform": "```hcl\nresource \"google_cloudfunctions2_function_iam_binding\" \"\" {\n project = \"\"\n location = \"\"\n cloud_function = \"\"\n role = \"roles/cloudfunctions.invoker\"\n members = [\"serviceAccount:\"] # Critical: never include allUsers or allAuthenticatedUsers\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to Cloud Function invocation: grant `roles/cloudfunctions.invoker` only to specific service accounts or groups.\n\nFor externally exposed functions, front them with **API Gateway** or **Cloud Endpoints** that enforce authentication and rate limiting.", + "Url": "https://hub.prowler.com/check/cloudfunction_function_not_publicly_accessible" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudfunction_function_inside_vpc", + "secretmanager_secret_not_publicly_accessible", + "cloudstorage_bucket_public_access" + ], + "Notes": "This check evaluates function-level IAM policies. Organization policy constraints/iam.allowedPolicyMemberDomains can prevent public bindings at the org level." +} diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py new file mode 100644 index 0000000000..14ee874f22 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible.py @@ -0,0 +1,44 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudfunction.cloudfunction_client import ( + cloudfunction_client, +) + + +class cloudfunction_function_not_publicly_accessible(Check): + """Check that Cloud Functions do not grant invocation rights to all users. + + Verifies that no active Cloud Function has an IAM binding granting access + to `allUsers` or `allAuthenticatedUsers`. Non-`ACTIVE` functions are + skipped because their IAM bindings are transient. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the public-access check across all Cloud Functions. + + Returns: + A list of `Check_Report_GCP` findings, one per active Cloud + Function. Status is `FAIL` when the function is invokable by + `allUsers` or `allAuthenticatedUsers` and `PASS` otherwise. + """ + findings = [] + for function in cloudfunction_client.functions: + if function.state != "ACTIVE": + continue + report = Check_Report_GCP( + metadata=self.metadata(), + resource=function, + resource_id=function.name, + ) + if function.publicly_accessible: + report.status = "FAIL" + report.status_extended = ( + f"Cloud Function {function.name} is publicly invocable " + f"(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Cloud Function {function.name} is not publicly accessible." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py new file mode 100644 index 0000000000..9905c98748 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py @@ -0,0 +1,146 @@ +from typing import Optional + +from googleapiclient import discovery +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class CloudFunction(GCPService): + """Cloud Functions v2 service client. + + Enumerates Cloud Functions across every accessible project and region + using the `cloudfunctions.googleapis.com` v2 API and exposes them through + the `functions` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload Cloud Functions.""" + super().__init__("cloudfunctions", provider, api_version="v2") + self.functions = [] + self._run_client = None + self._get_functions() + self._get_functions_iam_policy() + + def _get_functions(self) -> None: + """Fetch Cloud Functions for every project and location.""" + for project_id in self.project_ids: + try: + locations = self.client.projects().locations() + locations_request = locations.list(name=f"projects/{project_id}") + while locations_request is not None: + locations_response = locations_request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for location in locations_response.get("locations", []): + location_id = location["locationId"] + try: + functions = locations.functions() + request = functions.list( + parent=f"projects/{project_id}/locations/{location_id}" + ) + while request is not None: + response = request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for fn in response.get("functions", []): + service_config = fn.get("serviceConfig", {}) + self.functions.append( + Function( + id=fn["name"], + name=fn["name"].split("/")[-1], + project_id=project_id, + location=location_id, + state=fn.get("state", "UNKNOWN"), + environment=fn.get("environment", "GEN_1"), + service=service_config.get("service"), + vpc_connector=service_config.get( + "vpcConnector" + ), + ) + ) + request = functions.list_next( + previous_request=request, + previous_response=response, + ) + except Exception as error: + logger.error( + f"{location_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + locations_request = locations.list_next( + previous_request=locations_request, + previous_response=locations_response, + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_functions_iam_policy(self) -> None: + """Fetch IAM policy for every Cloud Function in parallel. + + For gen2 functions, IAM is delegated to the underlying Cloud Run + service, so a `run.googleapis.com` v2 client is required. + """ + if any(f.environment == "GEN_2" for f in self.functions): + self._run_client = discovery.build( + "run", + "v2", + credentials=self.credentials, + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + self.__threading_call__(self._get_function_iam_policy, self.functions) + + def _get_function_iam_policy(self, function: "Function") -> None: + """Mark a Cloud Function as publicly accessible when bound to `allUsers` or `allAuthenticatedUsers`. + + Cloud Functions gen2 delegates invocation IAM to its backing Cloud Run + service, so the binding is queried via the Run API. Gen1 functions are + queried through the Cloud Functions API directly. + """ + try: + if function.environment == "GEN_2" and function.service: + response = ( + self._run_client.projects() + .locations() + .services() + .getIamPolicy(resource=function.service) + .execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + ) + else: + response = ( + self.client.projects() + .locations() + .functions() + .getIamPolicy(resource=function.id) + .execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + ) + for binding in response.get("bindings", []): + members = binding.get("members", []) + if "allUsers" in members or "allAuthenticatedUsers" in members: + function.publicly_accessible = True + break + except Exception as error: + logger.error( + f"{function.location} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Function(BaseModel): + """Cloud Function resource consumed by GCP checks.""" + + id: str + name: str + project_id: str + location: str + state: str + environment: str = "GEN_1" + service: Optional[str] = None + vpc_connector: Optional[str] = None + publicly_accessible: bool = False diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..3e66f9cdd3 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "cloudsql_instance_high_availability_enabled", + "CheckTitle": "Cloud SQL instance has high availability (REGIONAL) configured", + "CheckType": [], + "ServiceName": "cloudsql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "sqladmin.googleapis.com/Instance", + "Description": "Ensures that Cloud SQL instances have high availability configured by setting availabilityType to REGIONAL. A REGIONAL instance maintains a standby replica in a different zone within the same region and automatically fails over on zone-level outages.", + "Risk": "Instances with ZONAL availability have no standby replica. A zone-level outage will cause database downtime until manual recovery, violating availability requirements and potentially breaching SLAs and ISMS-P 2.12.1 disaster preparedness controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/sql/docs/postgres/high-availability", + "https://cloud.google.com/sql/docs/sqlserver/high-availability" + ], + "Remediation": { + "Code": { + "CLI": "gcloud sql instances patch --availability-type=REGIONAL", + "NativeIaC": "", + "Other": "1. Go to Google Cloud Console > SQL > Instances.\n2. Click the instance name, then Edit.\n3. Under Availability, select Multiple zones (Highly available).\n4. Click Save.", + "Terraform": "```hcl\nresource \"google_sql_database_instance\" \"example\" {\n name = \"\"\n database_version = \"POSTGRES_15\"\n region = \"\"\n\n settings {\n tier = \"db-custom-2-7680\"\n\n availability_type = \"REGIONAL\" # Critical: enables HA standby replica\n\n backup_configuration {\n enabled = true\n start_time = \"02:00\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set availabilityType to REGIONAL for all production Cloud SQL instances. This creates a standby replica in a different zone and enables automatic failover, reducing RTO in the event of a zone outage.", + "Url": "https://hub.prowler.com/check/cloudsql_instance_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudsql_instance_automated_backups" + ], + "Notes": "Enabling HA increases instance cost approximately 2x due to the standby replica. ZONAL instances are acceptable for non-production workloads where downtime is tolerable." +} diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py new file mode 100644 index 0000000000..37bf576345 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudsql.cloudsql_client import cloudsql_client + + +class cloudsql_instance_high_availability_enabled(Check): + """Check that Cloud SQL primary instances are configured for high availability. + + Verifies that each Cloud SQL primary instance has `availabilityType` set to + `REGIONAL`, which provisions a standby replica in a different zone within + the same region and enables automatic failover on zone-level outages. Read + replicas are skipped because they inherit availability from their primary. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the high availability check across all Cloud SQL instances. + + Returns: + A list of `Check_Report_GCP` findings, one per Cloud SQL primary + instance. Status is `PASS` when `availability_type == "REGIONAL"` + and `FAIL` otherwise. + """ + findings = [] + for instance in cloudsql_client.instances: + if instance.instance_type != "CLOUD_SQL_INSTANCE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + if instance.availability_type == "REGIONAL": + report.status = "PASS" + report.status_extended = ( + f"Database instance {instance.name} has high availability " + f"(REGIONAL) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Database instance {instance.name} does not have high " + f"availability configured (current: " + f"{instance.availability_type})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py index 137169999a..1fe706bb02 100644 --- a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py @@ -46,6 +46,9 @@ class CloudSQL(GCPService): "authorizedNetworks", [] ), flags=settings.get("databaseFlags", []), + availability_type=settings.get( + "availabilityType", "ZONAL" + ), instance_type=instance.get( "instanceType", "CLOUD_SQL_INSTANCE" ), @@ -76,6 +79,7 @@ class Instance(BaseModel): ssl_mode: str automated_backups: bool flags: list + availability_type: str = "ZONAL" instance_type: str = "CLOUD_SQL_INSTANCE" cmek_key_name: Optional[str] = None project_id: str diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py index 3d5b0d1e79..7d8843584e 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -1,9 +1,12 @@ +import re + from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS from prowler.providers.gcp.gcp_provider import GcpProvider from prowler.providers.gcp.lib.service.service import GCPService +from prowler.providers.gcp.services.monitoring.monitoring_service import Monitoring class Logging(GCPService): @@ -121,9 +124,86 @@ class Metric(BaseModel): bucket_name: str = "" +# A positive selector of the Admin Activity stream: a ``logName`` predicate +# (``:`` has-substring or ``=`` equals) or a ``log_id()`` call. Written verbose +# so each fragment stays legible; ``(?![a-z_])`` keeps a longer stream name +# (``.../activity_v2``) from impersonating Admin Activity. +_ACTIVITY_SELECTOR = re.compile( + r""" + (?: logName \s* [:=] \s* | log_id \s* \( \s* ) # logName: / logName= / log_id( + ["']? [^"'\s)]* # optional quote, then path prefix + cloudaudit\.googleapis\.com/activity (?![a-z_]) # the Admin Activity stream itself + """, + re.IGNORECASE | re.VERBOSE, +) + +# The same selector for *any* Cloud Audit stream (activity, data_access, +# system_event, policy, access_transparency, …). Used to strip the OR-combined +# audit clauses so we can prove nothing restrictive is left over. +_CLOUDAUDIT_SELECTOR = re.compile( + r""" + (?: logName \s* [:=] \s* | log_id \s* \( \s* ) # logName: / logName= / log_id( + ["']? [^"'\s)]* # optional quote, then path prefix + cloudaudit\.googleapis\.com/[a-z_]+ # any cloudaudit stream + ["']? \s* \)? # optional closing quote / paren + """, + re.IGNORECASE | re.VERBOSE, +) + +# Operators that exclude or narrow coverage. Any of these means we cannot prove +# the sink delivers the *whole* Admin Activity stream, so it is not credited. +_NEGATION_OR_RESTRICTION = re.compile( + r""" + \bNOT\b # NOT exclusion + | \bAND\b # AND conjunction (restriction) + | != | !: # "!=" / "!:" inequality + | (?:^|[\s(]) -\s* [A-Za-z_] # leading "-" exclusion operator + """, + re.IGNORECASE | re.VERBOSE, +) + + +def _sink_delivers_activity_logs(sink_filter: str) -> bool: + """True only when a sink's filter *provably* exports the full Admin Activity + audit stream (or everything). + + Crediting flips a child project to PASS on a CIS security control, so the + match is deliberately conservative: a false FAIL is safe, a false PASS is + not. A non-``"all"`` filter is credited only when + + 1. it positively selects the Admin Activity stream + (``logName:.../activity``, ``logName="...activity"`` or + ``log_id("...activity")``); + 2. it carries no operator that excludes or narrows the stream — ``NOT`` / + ``-`` / ``!=`` (negation) or ``AND`` (restriction); and + 3. nothing but ``OR``-combined Cloud Audit selectors remains once those are + stripped — an ``OR`` only widens coverage, but any leftover predicate + (``severity>=ERROR``, ``resource.type=...``) could narrow it. + + Sink filters encode the stream URL-encoded (``...%2Factivity``) or as a path + — normalize before matching. + """ + if not sink_filter or sink_filter.strip().lower() == "all": + return True + normalized = sink_filter.replace("%2F", "/").replace("%2f", "/") + # 1. The Admin Activity stream must be positively selected. + if not _ACTIVITY_SELECTOR.search(normalized): + return False + # 2. No operator may exclude or narrow that coverage. + if _NEGATION_OR_RESTRICTION.search(normalized): + return False + # 3. Only OR-combined audit selectors may remain — strip them and the OR + # glue; anything left is a predicate we cannot prove is full-coverage. + remainder = _CLOUDAUDIT_SELECTOR.sub(" ", normalized) + remainder = re.sub(r"\bOR\b|[()\s]", " ", remainder, flags=re.IGNORECASE) + return remainder.strip() == "" + + def get_projects_covered_by_aggregated_metric( - logging_client, monitoring_client, metric_filter -): + logging_client: Logging, + monitoring_client: Monitoring, + metric_filter: str, +) -> dict[str, str]: """Return {project_id: metric_name} for scanned projects whose logs are routed, via an organization-level sink with includeChildren=True, to a bucket that holds a bucket-scoped log metric matching ``metric_filter`` that has an alert policy. @@ -133,6 +213,10 @@ def get_projects_covered_by_aggregated_metric( every child project's logs into one bucket, where a single bucket-scoped metric + alert covers them all. Without crediting that, those child projects are falsely failed. Mirrors the org-sink handling already in ``logging_sink_created`` (#11355). + + A sink is credited when it exports everything (``filter == "all"``) or when its + filter carries the Admin Activity audit stream — the only stream the CIS metric + filters can match (see ``_sink_delivers_activity_logs``). """ # Buckets that hold a matching, alerted, bucket-scoped metric -> metric name. bucket_to_metric = {} @@ -155,7 +239,7 @@ def get_projects_covered_by_aggregated_metric( for sink in logging_client.sinks: if not getattr(sink, "include_children", False): continue - if getattr(sink, "filter", "all") != "all": + if not _sink_delivers_activity_logs(getattr(sink, "filter", "all")): continue for bucket, metric_name in bucket_to_metric.items(): # sink.destination e.g. "logging.googleapis.com/projects/.../buckets/X"; diff --git a/prowler/providers/gcp/services/secretmanager/__init__.py b/prowler/providers/gcp/services/secretmanager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_client.py b/prowler/providers/gcp/services/secretmanager/secretmanager_client.py new file mode 100644 index 0000000000..dc18866cb3 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + SecretManager, +) + +secretmanager_client = SecretManager(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json new file mode 100644 index 0000000000..9f1f4d3381 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "secretmanager_secret_not_publicly_accessible", + "CheckTitle": "Secret Manager secret is not publicly accessible", + "CheckType": [], + "ServiceName": "secretmanager", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "secretmanager.googleapis.com/Secret", + "Description": "Secret Manager secrets deny access to `allUsers` and `allAuthenticatedUsers`, so only **explicitly authorized identities** can read or use them.\n\nThe evaluation reviews each secret's IAM policy bindings to confirm no public principals are granted access.", + "Risk": "Granting public IAM access to a secret exposes credentials, API keys, certificates, or other sensitive values to any caller on the internet or any authenticated Google account. This compromises **confidentiality**, enables **lateral movement** with leaked credentials, and can trigger regulatory breaches under PCI-DSS, ISO 27001, and GDPR.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/secret-manager/docs/access-control", + "https://cloud.google.com/iam/docs/overview" + ], + "Remediation": { + "Code": { + "CLI": "gcloud secrets remove-iam-policy-binding --member= --role=roles/secretmanager.secretAccessor", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Security > Secret Manager\n2. Select the secret and open the Permissions tab\n3. Remove any binding with allUsers or allAuthenticatedUsers\n4. Grant access only to required service accounts or groups", + "Terraform": "```hcl\nresource \"google_secret_manager_secret_iam_binding\" \"\" {\n secret_id = \"\"\n role = \"roles/secretmanager.secretAccessor\"\n members = [\"serviceAccount:\"] # Critical: never include allUsers or allAuthenticatedUsers\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to Secret Manager: grant `roles/secretmanager.secretAccessor` only to the specific service accounts or groups that need to read each secret.\n\nUse **workload identity** for GKE workloads and rotate credentials regularly to limit blast radius when a binding is misconfigured.", + "Url": "https://hub.prowler.com/check/secretmanager_secret_not_publicly_accessible" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "secretmanager_secret_rotation_enabled", + "kms_key_not_publicly_accessible", + "cloudstorage_bucket_public_access" + ], + "Notes": "This check evaluates the secret-level IAM policy. Project-level IAM policies that may indirectly grant public access are not evaluated by this check." +} diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py new file mode 100644 index 0000000000..fb52a119fa --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.secretmanager.secretmanager_client import ( + secretmanager_client, +) + + +class secretmanager_secret_not_publicly_accessible(Check): + """Check that Secret Manager secrets do not grant access to all users. + + Verifies that no Secret Manager secret has an IAM binding granting access + to `allUsers` or `allAuthenticatedUsers`. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the public-access check across all Secret Manager secrets. + + Returns: + A list of `Check_Report_GCP` findings, one per secret. Status is + `FAIL` when the secret is accessible to `allUsers` or + `allAuthenticatedUsers` and `PASS` otherwise. + """ + findings = [] + for secret in secretmanager_client.secrets: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=secret, + resource_id=secret.name, + ) + if secret.publicly_accessible: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} is publicly accessible " + f"(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Secret {secret.name} is not publicly accessible." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_service.py b/prowler/providers/gcp/services/secretmanager/secretmanager_service.py new file mode 100644 index 0000000000..6a193d8ece --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_service.py @@ -0,0 +1,87 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class SecretManager(GCPService): + """Secret Manager service client. + + Enumerates Secret Manager secrets across every accessible project using + the `secretmanager.googleapis.com` v1 API and exposes them through the + `secrets` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload secrets with their IAM policies.""" + super().__init__("secretmanager", provider) + self.secrets = [] + self._get_secrets() + self._get_secrets_iam_policy() + + def _get_secrets(self) -> None: + """Fetch Secret Manager secrets for every project.""" + for project_id in self.project_ids: + try: + request = ( + self.client.projects() + .secrets() + .list(parent=f"projects/{project_id}") + ) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for secret in response.get("secrets", []): + self.secrets.append( + Secret( + id=secret["name"], + name=secret["name"].split("/")[-1], + project_id=project_id, + ) + ) + request = ( + self.client.projects() + .secrets() + .list_next(previous_request=request, previous_response=response) + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_secrets_iam_policy(self) -> None: + """Fetch IAM policy for every Secret Manager secret in parallel.""" + self.__threading_call__(self._get_secret_iam_policy, self.secrets) + + def _get_secret_iam_policy(self, secret: "Secret") -> None: + """Mark a secret as publicly accessible when bound to `allUsers` or `allAuthenticatedUsers`.""" + try: + response = ( + self.client.projects() + .secrets() + .getIamPolicy(resource=secret.id) + .execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + ) + for binding in response.get("bindings", []): + members = binding.get("members", []) + if "allUsers" in members or "allAuthenticatedUsers" in members: + secret.publicly_accessible = True + break + except Exception as error: + logger.error( + f"{secret.project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Secret(BaseModel): + """Secret Manager secret resource consumed by GCP checks.""" + + id: str + name: str + project_id: str + location: str = "global" + publicly_accessible: bool = False diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/__init__.py b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json new file mode 100644 index 0000000000..5831cb2abd --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_cpu_limits_set", + "CheckTitle": "Pod containers have CPU limits set", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure CPU limits are set for containers to prevent noisy neighbors and resource exhaustion.", + "Risk": "Missing CPU limits can allow containers to consume unbounded CPU leading to performance issues.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -n --containers= --limits=cpu=500m", + "NativeIaC": "# Example: set cpu limits in container resources\nresources:\n limits:\n cpu: \"500m\"", + "Other": "1. Edit the Pod/Deployment manifest and set `resources.limits.cpu` for each container.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Define CPU limits to bound a container's CPU usage and protect node stability.", + "Url": "https://hub.prowler.com/check/core_cpu_limits_set" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Limits should be tuned per workload and monitored." +} diff --git a/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py new file mode 100644 index 0000000000..8f22fa6b28 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_cpu_limits_set(Check): + """Check whether regular pod containers have CPU limits configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod CPU limits check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} regular containers have CPU limits configured." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + limits = ( + resources.get("limits") if isinstance(resources, dict) else None + ) + cpu = limits.get("cpu") if limits and isinstance(limits, dict) else None + if not cpu: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a CPU limit configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/__init__.py b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json new file mode 100644 index 0000000000..9f3972e271 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_cpu_requests_set", + "CheckTitle": "Pod containers have CPU requests set", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure CPU requests are set for containers to enable proper scheduling and resource guarantees.", + "Risk": "Missing CPU requests can lead to scheduling and resource contention issues.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -n --containers= --requests=cpu=100m", + "NativeIaC": "# Example: set cpu requests in container resources\nresources:\n requests:\n cpu: \"100m\"", + "Other": "1. Edit the Pod/Deployment manifest and set `resources.requests.cpu` for each container.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Define sensible CPU requests to enable efficient scheduling and fair resource allocation.", + "Url": "https://hub.prowler.com/check/core_cpu_requests_set" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Requests should be tuned per workload and cluster capacity." +} diff --git a/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py new file mode 100644 index 0000000000..6e7f9f8020 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_cpu_requests_set(Check): + """Check whether regular pod containers have CPU requests configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod CPU requests check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} regular containers have CPU requests configured." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + requests = ( + resources.get("requests") if isinstance(resources, dict) else None + ) + cpu = ( + requests.get("cpu") + if requests and isinstance(requests, dict) + else None + ) + if not cpu: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a CPU request configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/__init__.py b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json new file mode 100644 index 0000000000..3a395caad1 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_image_tag_fixed", + "CheckTitle": "Container images use fixed tags", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers using images without a **fixed version tag**, such as `latest` or no tag at all, which results in unpredictable image versions being pulled.", + "Risk": "Using **`latest` or untagged images** breaks reproducibility and introduces risk:\n- Deployments may pull different image versions across nodes (integrity)\n- Rollbacks become unreliable when the exact image is unknown\n- Supply-chain attacks can inject malicious code via mutable tags\nCompromised or unexpected images can lead to **data breaches**, **lateral movement**, and **service disruption**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/containers/images/", + "https://kubernetes.io/docs/concepts/configuration/overview/#container-images" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set image deployment/ =: -n \n# Example: kubectl set image deployment/nginx nginx=nginx:1.25.3 -n default\n# For maximum immutability, use an image digest instead: @sha256:", + "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. Update the image field for each affected container to include a specific version tag or digest:\n - Example: change `nginx` or `nginx:latest` to `nginx:1.25.3` or `nginx@sha256:`\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 = \"nginx:1.25.3\" # Critical: use a fixed version tag, never 'latest' or untagged\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Pin all container images to a **specific version tag** or **digest** (`@sha256:...`) to ensure reproducible, auditable deployments.\n\n- Avoid `latest` and untagged images in production\n- Use **image digests** for maximum immutability\n- Enforce tag policies with **admission controllers** (e.g., OPA Gatekeeper, Kyverno)\n- Integrate image scanning into CI/CD pipelines for **defense in depth**", + "Url": "https://hub.prowler.com/check/core_image_tag_fixed" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Using fixed image tags ensures reproducible deployments and reduces supply-chain attack surface." +} diff --git a/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py new file mode 100644 index 0000000000..5167d09f2c --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed.py @@ -0,0 +1,50 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +def _has_fixed_image_tag(image: str) -> bool: + if "@" in image: + return True + + image_name = image.rsplit("/", 1)[-1] + if ":" not in image_name: + return False + + tag = image_name.rsplit(":", 1)[-1] + return bool(tag) and tag.lower() != "latest" + + +class core_image_tag_fixed(Check): + """Ensure that image tag is not set to latest or blank.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has fixed image tags on all containers." + ) + + for containers in ( + pod.containers, + pod.init_containers, + pod.ephemeral_containers, + ): + for container in (containers or {}).values(): + image = container.image + if not _has_fixed_image_tag(image): + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} has container {container.name} with image '{image}' that does not use a fixed tag." + break + if report.status == "FAIL": + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/__init__.py b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json new file mode 100644 index 0000000000..668551bca4 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_liveness_probe_configured", + "CheckTitle": "Pod containers have liveness probes configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure each regular pod container has a liveness probe configured to detect and restart unhealthy containers.", + "Risk": "Without liveness probes, failed containers may remain running causing degraded service or resource waste.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/", + "https://sre.google/workbook/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl patch deployment/ -n --type='json' -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/livenessProbe\",\"value\":{\"httpGet\":{\"path\":\"/healthz\",\"port\":8080},\"initialDelaySeconds\":10,\"periodSeconds\":10}}]'", + "NativeIaC": "# Example: add a livenessProbe to your container spec\nlivenessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 10\n periodSeconds: 10", + "Other": "1. Edit the Pod/Deployment manifest and add a `livenessProbe` to each container.\n2. Tune `initialDelaySeconds`, `periodSeconds` and failure thresholds to your app.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add and tune liveness probes for containers to allow Kubernetes to detect and restart unhealthy containers.", + "Url": "https://hub.prowler.com/check/core_liveness_probe_configured" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Presence of a probe is checked; adjust thresholds for app behavior to avoid false positives." +} diff --git a/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py new file mode 100644 index 0000000000..0bfbc618d2 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_liveness_probe_configured(Check): + """Check whether regular pod containers have liveness probes configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod liveness probe check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} has liveness probes configured for all regular containers." + + for container in (pod.containers or {}).values(): + if not container.liveness_probe: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a liveness probe configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/__init__.py b/prowler/providers/kubernetes/services/core/core_memory_limits_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json new file mode 100644 index 0000000000..8b9a121bf2 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_memory_limits_set", + "CheckTitle": "Container memory limits are configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers without **memory limits** configured in `resources.limits.memory`, indicating unbounded memory consumption is permitted.", + "Risk": "Without **memory limits**, a single container can consume all available node memory, causing:\n- **OOM kills** of other workloads (availability)\n- Node instability and cascading failures\n- Noisy-neighbor problems in multi-tenant clusters\nAttackers exploiting a vulnerability can amplify impact by exhausting node resources, enabling **denial of service** across co-located workloads.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + "https://kubernetes.io/docs/tasks/administer-cluster/manage-resources/memory-default-namespace/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -c --limits=memory= -n \n# Example: kubectl set resources deployment/nginx -c nginx --limits=memory=128Mi -n default", + "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 resources.limits.memory for each affected container:\n - For controllers: spec.template.spec.containers[].resources.limits.memory\n - For standalone Pods: spec.containers[].resources.limits.memory\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 resources {\n limits = {\n memory = \"128Mi\" # Critical: sets a memory ceiling to prevent unbounded consumption\n }\n }\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set **memory limits** on every container to prevent unbounded consumption and protect node stability.\n\n- Start with observed usage plus headroom; tune with **VPA** recommendations\n- Combine with **LimitRanges** and **ResourceQuotas** at the namespace level for **defense in depth**\n- Monitor memory usage and OOMKill events to right-size limits over time", + "Url": "https://hub.prowler.com/check/core_memory_limits_set" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Memory limits prevent containers from consuming unbounded memory and protect node stability." +} diff --git a/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py new file mode 100644 index 0000000000..2fd623c8d5 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_memory_limits_set(Check): + """Ensure that memory limits are set on all containers.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has memory limits set on all containers." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + limits = ( + resources.get("limits") if isinstance(resources, dict) else None + ) + memory = ( + limits.get("memory") + if limits and isinstance(limits, dict) + else None + ) + if not memory: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} does not have memory limits set on container {container.name}." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/__init__.py b/prowler/providers/kubernetes/services/core/core_memory_requests_set/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json new file mode 100644 index 0000000000..1871884621 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_memory_requests_set", + "CheckTitle": "Container memory requests are configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Pod", + "ResourceGroup": "container", + "Description": "**Kubernetes Pods** are evaluated for containers without **memory requests** configured in `resources.requests.memory`, indicating the scheduler cannot guarantee memory allocation.", + "Risk": "Without **memory requests**, the scheduler cannot make informed placement decisions, leading to:\n- Overcommitted nodes that trigger **OOM kills** (availability)\n- Unpredictable performance under load\n- Inability to enforce **ResourceQuotas** effectively\nMissing requests weaken cluster stability and make capacity planning unreliable, increasing blast radius during incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + "https://kubernetes.io/docs/tasks/administer-cluster/manage-resources/memory-default-namespace/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl set resources deployment/ -c --requests=memory= -n \n# Example: kubectl set resources deployment/nginx -c nginx --requests=memory=64Mi -n default", + "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 resources.requests.memory for each affected container:\n - For controllers: spec.template.spec.containers[].resources.requests.memory\n - For standalone Pods: spec.containers[].resources.requests.memory\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 resources {\n requests = {\n memory = \"64Mi\" # Critical: guarantees memory for the scheduler to make informed placement\n }\n }\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set **memory requests** on every container so the scheduler can guarantee memory allocation and place pods effectively.\n\n- Base requests on observed steady-state usage; use **VPA** for data-driven sizing\n- Enforce minimum requests via **LimitRanges** at the namespace level\n- Combine with **ResourceQuotas** for **defense in depth** against overcommitment", + "Url": "https://hub.prowler.com/check/core_memory_requests_set" + } + }, + "Categories": [ + "container-security" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Memory requests enable the scheduler to guarantee memory allocation and prevent overcommitment." +} diff --git a/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py new file mode 100644 index 0000000000..a560365e32 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_memory_requests_set(Check): + """Ensure that memory requests are set on all containers.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the check logic. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = ( + f"Pod {pod.name} has memory requests set on all containers." + ) + + for container in (pod.containers or {}).values(): + resources = container.resources or {} + requests = ( + resources.get("requests") if isinstance(resources, dict) else None + ) + memory = ( + requests.get("memory") + if requests and isinstance(requests, dict) + else None + ) + if not memory: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} does not have memory requests set on container {container.name}." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/__init__.py b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json new file mode 100644 index 0000000000..5c61381064 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.metadata.json @@ -0,0 +1,35 @@ +{ + "Provider": "kubernetes", + "CheckID": "core_readiness_probe_configured", + "CheckTitle": "Regular pod containers have readiness probes configured", + "CheckType": [], + "ServiceName": "core", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "KubernetesPod", + "Description": "Ensure each regular pod container has a readiness probe configured to signal when it is ready to serve traffic.", + "Risk": "Without readiness probes, services may receive traffic before containers are ready, causing errors.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/" + ], + "Remediation": { + "Code": { + "CLI": "kubectl patch deployment/ -n --type='json' -p='[{\"op\":\"add\",\"path\":\"/spec/template/spec/containers/0/readinessProbe\",\"value\":{\"httpGet\":{\"path\":\"/ready\",\"port\":8080},\"initialDelaySeconds\":5,\"periodSeconds\":5}}]'", + "NativeIaC": "# Example readiness probe\nreadinessProbe:\n httpGet:\n path: /ready\n port: 8080\n initialDelaySeconds: 5\n periodSeconds: 5", + "Other": "1. Add a `readinessProbe` to the container spec in Deployments/Pods.\n2. Ensure the probe accurately reflects service readiness.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add readiness probes to prevent routing traffic to unready containers.", + "Url": "https://hub.prowler.com/check/core_readiness_probe_configured" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Readiness and liveness probes serve different purposes; implement both where appropriate." +} diff --git a/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py new file mode 100644 index 0000000000..847f9b4b97 --- /dev/null +++ b/prowler/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_Kubernetes +from prowler.providers.kubernetes.services.core.core_client import core_client + + +class core_readiness_probe_configured(Check): + """Check whether regular pod containers have readiness probes configured.""" + + def execute(self) -> list[Check_Report_Kubernetes]: + """Execute the Kubernetes pod readiness probe check. + + Returns: + List of check reports for Kubernetes pods. + """ + findings = [] + for pod in core_client.pods.values(): + report = Check_Report_Kubernetes(metadata=self.metadata(), resource=pod) + report.status = "PASS" + report.status_extended = f"Pod {pod.name} has readiness probes configured for all regular containers." + + for container in (pod.containers or {}).values(): + if not container.readiness_probe: + report.status = "FAIL" + report.status_extended = f"Pod {pod.name} container {container.name} does not have a readiness probe configured." + break + + findings.append(report) + + return findings diff --git a/prowler/providers/kubernetes/services/core/core_service.py b/prowler/providers/kubernetes/services/core/core_service.py index e685140a87..b53a81779c 100644 --- a/prowler/providers/kubernetes/services/core/core_service.py +++ b/prowler/providers/kubernetes/services/core/core_service.py @@ -27,45 +27,11 @@ class Core(KubernetesService): for namespace in self.namespaces: pods = self.client.list_namespaced_pod(namespace) for pod in pods.items: - pod_containers = {} - containers = pod.spec.containers if pod.spec.containers else [] - init_containers = ( - pod.spec.init_containers if pod.spec.init_containers else [] - ) - ephemeral_containers = ( + containers = self._build_containers(pod.spec.containers) + init_containers = self._build_containers(pod.spec.init_containers) + ephemeral_containers = self._build_containers( pod.spec.ephemeral_containers - if pod.spec.ephemeral_containers - else [] ) - for container in ( - containers + init_containers + ephemeral_containers - ): - pod_containers[container.name] = Container( - name=container.name, - image=container.image, - command=container.command if container.command else None, - ports=( - [ - {"containerPort": port.container_port} - for port in container.ports - ] - if container.ports - else None - ), - env=( - [ - {"name": env.name, "value": env.value} - for env in container.env - ] - if container.env - else None - ), - security_context=( - container.security_context.to_dict() - if container.security_context - else {} - ), - ) self.pods[pod.metadata.uid] = Pod( name=pod.metadata.name, uid=pod.metadata.uid, @@ -85,13 +51,56 @@ class Core(KubernetesService): if pod.spec.security_context else {} ), - containers=pod_containers, + containers=containers, + init_containers=init_containers, + ephemeral_containers=ephemeral_containers, ) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + @staticmethod + def _build_containers(containers) -> dict: + pod_containers = {} + for container in containers or []: + pod_containers[container.name] = Container( + name=container.name, + image=container.image, + command=container.command if container.command else None, + ports=( + [{"containerPort": port.container_port} for port in container.ports] + if container.ports + else None + ), + env=( + [{"name": env.name, "value": env.value} for env in container.env] + if container.env + else None + ), + security_context=( + container.security_context.to_dict() + if container.security_context + else {} + ), + resources=( + container.resources.to_dict() + if getattr(container, "resources", None) + else None + ), + liveness_probe=( + container.liveness_probe.to_dict() + if getattr(container, "liveness_probe", None) + else None + ), + readiness_probe=( + container.readiness_probe.to_dict() + if getattr(container, "readiness_probe", None) + else None + ), + ) + return pod_containers + def _list_config_maps(self): try: response = self.client.list_config_map_for_all_namespaces() @@ -156,6 +165,9 @@ class Container(BaseModel): ports: Optional[List[dict]] env: Optional[List[dict]] security_context: dict + resources: Optional[dict] = None + liveness_probe: Optional[dict] = None + readiness_probe: Optional[dict] = None class Pod(BaseModel): @@ -174,6 +186,8 @@ class Pod(BaseModel): host_network: Optional[bool] security_context: Optional[dict] containers: Optional[dict] + init_containers: Optional[dict] = None + ephemeral_containers: Optional[dict] = None class ConfigMap(BaseModel): diff --git a/prowler/providers/linode/__init__.py b/prowler/providers/linode/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/exceptions/__init__.py b/prowler/providers/linode/exceptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/exceptions/exceptions.py b/prowler/providers/linode/exceptions/exceptions.py new file mode 100644 index 0000000000..6c39f477db --- /dev/null +++ b/prowler/providers/linode/exceptions/exceptions.py @@ -0,0 +1,106 @@ +from prowler.exceptions.exceptions import ProwlerException + + +# Exceptions codes from 18000 to 18099 are reserved for Linode exceptions +class LinodeBaseException(ProwlerException): + """Base class for Linode errors.""" + + LINODE_ERROR_CODES = { + (18000, "LinodeCredentialsError"): { + "message": "Linode credentials not found or invalid", + "remediation": "Provide a valid Personal Access Token for Linode via the LINODE_TOKEN environment variable.", + }, + (18001, "LinodeAuthenticationError"): { + "message": "Linode authentication failed", + "remediation": "Verify the Linode Personal Access Token and ensure it has the required scopes (linodes:read_only, firewalls:read_only, account:read_only).", + }, + (18002, "LinodeSessionError"): { + "message": "Linode session setup failed", + "remediation": "Review the Linode SDK initialization parameters and credentials.", + }, + (18003, "LinodeIdentityError"): { + "message": "Unable to retrieve Linode identity or account information", + "remediation": "Ensure the Personal Access Token allows access to the Linode account and profile APIs.", + }, + (18004, "LinodeMissingPermissionError"): { + "message": "Linode token is missing a required permission scope", + "remediation": "Grant the Personal Access Token the read-only scope required for the affected service (account:read_only, linodes:read_only, firewall:read_only).", + }, + (18005, "LinodeInvalidRegionError"): { + "message": "One or more requested Linode regions are invalid", + "remediation": "Pass a valid Linode region id to --region. See https://www.linode.com/global-infrastructure/ or the API /v4/regions endpoint for the current list.", + }, + } + + def __init__(self, code, file=None, original_exception=None, message=None): + provider = "Linode" + error_info = self.LINODE_ERROR_CODES.get((code, self.__class__.__name__)) + if error_info is None: + error_info = { + "message": message or "Unknown Linode error", + "remediation": "Check the Linode 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 LinodeCredentialsError(LinodeBaseException): + """Exception for Linode credential errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18000, file=file, original_exception=original_exception, message=message + ) + + +class LinodeAuthenticationError(LinodeBaseException): + """Exception for Linode authentication errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18001, file=file, original_exception=original_exception, message=message + ) + + +class LinodeSessionError(LinodeBaseException): + """Exception for Linode session setup errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18002, file=file, original_exception=original_exception, message=message + ) + + +class LinodeIdentityError(LinodeBaseException): + """Exception for Linode identity errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18003, file=file, original_exception=original_exception, message=message + ) + + +class LinodeMissingPermissionError(LinodeBaseException): + """Exception for Linode missing permission scope errors.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18004, file=file, original_exception=original_exception, message=message + ) + + +class LinodeInvalidRegionError(LinodeBaseException): + """Exception for invalid Linode region filters.""" + + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 18005, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/linode/lib/__init__.py b/prowler/providers/linode/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/arguments/__init__.py b/prowler/providers/linode/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/arguments/arguments.py b/prowler/providers/linode/lib/arguments/arguments.py new file mode 100644 index 0000000000..b8af36dba9 --- /dev/null +++ b/prowler/providers/linode/lib/arguments/arguments.py @@ -0,0 +1,24 @@ +def init_parser(self): + """Init the Linode provider CLI parser.""" + linode_parser = self.subparsers.add_parser( + "linode", parents=[self.common_providers_parser], help="Linode Provider" + ) + + # Authentication + # Credentials are read exclusively from the standard Linode environment + # variable (LINODE_TOKEN) to avoid leaking secrets into shell history and + # process listings. There are no credential CLI flags. + + # Regions + regions_subparser = linode_parser.add_argument_group("Regions") + regions_subparser.add_argument( + "--region", + "--filter-region", + "-f", + nargs="+", + default=None, + metavar="REGION", + help="Linode region(s) to scan (e.g. eu-central us-east). Region-less " + "resources (account, networking) are always scanned. If omitted, all " + "regions are scanned.", + ) diff --git a/prowler/providers/linode/lib/mutelist/__init__.py b/prowler/providers/linode/lib/mutelist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/mutelist/mutelist.py b/prowler/providers/linode/lib/mutelist/mutelist.py new file mode 100644 index 0000000000..c7da04a58a --- /dev/null +++ b/prowler/providers/linode/lib/mutelist/mutelist.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import CheckReportLinode +from prowler.lib.mutelist.mutelist import Mutelist +from prowler.lib.outputs.utils import unroll_dict, unroll_tags + + +class LinodeMutelist(Mutelist): + """Linode-specific mutelist helper.""" + + def is_finding_muted( + self, + finding: CheckReportLinode, + account_id: str, + ) -> bool: + """ + Check if a Linode finding is muted. + + Args: + finding: CheckReportLinode instance containing check metadata, region, resource info, and tags. + account_id: Linode account identifier. + + Returns: + True if the finding is muted, False otherwise. + """ + return self.is_muted( + account_id, + finding.check_metadata.CheckID, + finding.region or "global", + finding.resource_id or finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) diff --git a/prowler/providers/linode/lib/service/__init__.py b/prowler/providers/linode/lib/service/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/lib/service/service.py b/prowler/providers/linode/lib/service/service.py new file mode 100644 index 0000000000..d1a9de65db --- /dev/null +++ b/prowler/providers/linode/lib/service/service.py @@ -0,0 +1,86 @@ +import os +from concurrent.futures import ThreadPoolExecutor, as_completed + +from prowler.lib.logger import logger +from prowler.providers.linode.exceptions.exceptions import LinodeMissingPermissionError +from prowler.providers.linode.linode_provider import LinodeProvider + +MAX_WORKERS = 10 + + +class LinodeService: + """Base class for Linode services to share provider context.""" + + def __init__(self, service: str, provider: LinodeProvider): + """ + Initialize the Linode service with provider context. + + Args: + service: The Linode service name (e.g., administration, compute, networking). + provider: LinodeProvider instance containing session, audit config, and fixer config. + """ + self.provider = provider + self.client = provider.session.client + self.audit_config = provider.audit_config + self.fixer_config = provider.fixer_config + self.service = service.lower() if not service.islower() else service + + self.thread_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) + + def _log_fetch_error( + self, resource_label: str, required_scope: str, error: Exception + ) -> None: + """Log a resource-fetch failure, distinguishing an insufficient-scope + (HTTP 401/403) error from a generic API error. + + This never raises: a single service's missing permission must not abort + the rest of the scan. When the token lacks the required scope, the log + names the exact scope to grant via ``LinodeMissingPermissionError``. + + Args: + resource_label: Human-readable resource name (e.g. "firewalls"). + required_scope: The Linode OAuth scope needed (e.g. "firewall:read_only"). + error: The exception raised by the SDK call. + """ + service_name = getattr(self, "service", "linode") + status = getattr(error, "status", None) + if status in (401, 403) or "not authorized to use this endpoint" in str(error): + logger.error( + str( + LinodeMissingPermissionError( + file=os.path.basename(__file__), + message=( + f"{service_name} - unable to list {resource_label}: the Linode " + f"token lacks the '{required_scope}' scope; skipping these checks." + ), + original_exception=error, + ) + ) + ) + else: + logger.error( + f"{service_name} - Error fetching {resource_label}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + 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/linode/linode_provider.py b/prowler/providers/linode/linode_provider.py new file mode 100644 index 0000000000..ab069fab3d --- /dev/null +++ b/prowler/providers/linode/linode_provider.py @@ -0,0 +1,343 @@ +import logging +import os + +from colorama import Fore, Style +from linode_api4 import LinodeClient + +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.linode.exceptions.exceptions import ( + LinodeAuthenticationError, + LinodeCredentialsError, + LinodeIdentityError, + LinodeInvalidRegionError, + LinodeSessionError, +) +from prowler.providers.linode.lib.mutelist.mutelist import LinodeMutelist +from prowler.providers.linode.models import ( + LinodeIdentityInfo, + LinodeSession, +) + + +class LinodeProvider(Provider): + """Linode provider.""" + + _type: str = "linode" + _session: LinodeSession + _identity: LinodeIdentityInfo + _audit_config: dict + _fixer_config: dict + _mutelist: LinodeMutelist + _regions: set + audit_metadata: Audit_Metadata + + def __init__( + self, + config_path: str = None, + config_content: dict | None = None, + fixer_config: dict | None = None, + mutelist_path: str = None, + mutelist_content: dict = None, + token: str = None, + regions: list = None, + ): + """ + Initializes the LinodeProvider instance. + + Args: + config_path (str): Path to the configuration file. + config_content (dict): Audit configuration content. + fixer_config (dict): Fixer configuration. + mutelist_path (str): Path to the mutelist file. + mutelist_content (dict): Mutelist content. + token (str): Linode Personal Access Token (falls back to LINODE_TOKEN env var). + regions (list): Region(s) to scan regional resources in. Region-less + resources are always scanned. ``None`` scans all regions. + + Raises: + LinodeCredentialsError: If no token is provided. + LinodeSessionError: If the Linode session cannot be established. + LinodeIdentityError: If user or account identity cannot be retrieved. + """ + logger.info("Instantiating Linode provider...") + + # Mute noisy HTTP client logs + logging.getLogger("urllib3").setLevel(logging.WARNING) + + 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 = LinodeProvider.setup_session(token=token) + + # Region filter for regional resources, validated against the live + # Linode regions list. None means scan all regions. + self._regions = LinodeProvider.validate_regions(self._session, regions) + + self._identity = LinodeProvider.setup_identity(self._session) + + self._fixer_config = fixer_config if fixer_config is not None else {} + + if mutelist_content: + self._mutelist = LinodeMutelist(mutelist_content=mutelist_content) + else: + if not mutelist_path: + mutelist_path = get_default_mute_file_path(self.type) + self._mutelist = LinodeMutelist(mutelist_path=mutelist_path) + + 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) -> LinodeMutelist: + return self._mutelist + + @property + def regions(self): + """Set of regions to scan for regional resources, or None for all.""" + return self._regions + + def validate_arguments(self) -> None: + """Linode provider has no provider-specific arguments to validate.""" + return None + + @staticmethod + def setup_session(token: str = None) -> LinodeSession: + """Initialize Linode SDK client. + + Credentials can be provided as argument or read from environment variable: + - LINODE_TOKEN (Personal Access Token) + + Args: + token: Linode Personal Access Token (optional, falls back to env var). + + Returns: + LinodeSession: The initialized Linode session. + + Raises: + LinodeCredentialsError: If no credentials are provided. + LinodeSessionError: If session setup fails. + """ + token = token or os.environ.get("LINODE_TOKEN", "") + + if not token: + raise LinodeCredentialsError( + file=os.path.basename(__file__), + message="Linode credentials not found. Set the LINODE_TOKEN environment variable.", + ) + + try: + client = LinodeClient(token) + return LinodeSession(client=client, token=token) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeSessionError( + file=os.path.basename(__file__), + original_exception=error, + ) + + @staticmethod + def validate_regions(session: LinodeSession, regions: list = None): + """Validate the requested regions against the live Linode regions list. + + The ``/v4/regions`` endpoint is public, so this works regardless of the + token's scope. Validating against the live list (instead of a static + file) avoids rejecting newly added Linode regions. + + Args: + session: The Linode session. + regions: The region ids requested via --region (or None). + + Returns: + The validated set of region ids, or None when no filter is given. + + Raises: + LinodeInvalidRegionError: If any requested region id is unknown. + """ + if not regions: + return None + + requested = set(regions) + try: + available = {region.id for region in session.client.regions()} + except Exception as error: + # Do not block a scan if the regions list cannot be fetched. + logger.warning( + f"Unable to validate Linode regions: {error}. " + "Proceeding with the requested regions without validation." + ) + return requested + + invalid = requested - available + if invalid: + raise LinodeInvalidRegionError( + file=os.path.basename(__file__), + message=( + f"Invalid Linode region(s): {', '.join(sorted(invalid))}. " + f"Valid regions are: {', '.join(sorted(available))}." + ), + ) + return requested + + @staticmethod + def setup_identity(session: LinodeSession) -> LinodeIdentityInfo: + """Fetch user and account metadata for Linode. + + The authenticated user's profile is retrieved first to validate the + token. Any valid token can read its own profile, so a failure here + (for example a ``401 Invalid Token``) means the credentials are invalid + and the scan is aborted instead of silently returning empty results. + + Args: + session: The Linode session. + + Returns: + LinodeIdentityInfo: The identity information. + + Raises: + LinodeAuthenticationError: If the token is invalid. + LinodeIdentityError: If identity setup fails unexpectedly. + """ + try: + client = session.client + username = None + email = None + account_id = None + + # Validate the token by reading the authenticated user's profile. + # A failure here means the credentials are invalid, so abort. + try: + profile = client.profile() + username = profile.username + email = profile.email + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + + # The account endpoint requires the account:read_only scope. A + # token without that scope is still valid, so continue without the + # account ID instead of failing the scan. + try: + account = client.account() + account_id = getattr(account, "euuid", None) + except Exception as error: + logger.warning( + f"Unable to retrieve Linode account info: {error}. Continuing without account ID." + ) + + return LinodeIdentityInfo( + username=username, + email=email, + account_id=account_id, + ) + except LinodeAuthenticationError: + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + raise LinodeIdentityError( + file=os.path.basename(__file__), + original_exception=error, + ) + + def print_credentials(self) -> None: + report_title = ( + f"{Style.BRIGHT}Using the Linode credentials below:{Style.RESET_ALL}" + ) + report_lines = [] + + report_lines.append( + f"Authentication: {Fore.YELLOW}Personal Access Token{Style.RESET_ALL}" + ) + + if self.identity.username: + report_lines.append( + f"Username: {Fore.YELLOW}{self.identity.username}{Style.RESET_ALL}" + ) + + if self.identity.email: + report_lines.append( + f"Email: {Fore.YELLOW}{self.identity.email}{Style.RESET_ALL}" + ) + + if self.identity.account_id: + report_lines.append( + f"Account ID: {Fore.YELLOW}{self.identity.account_id}{Style.RESET_ALL}" + ) + + print_boxes(report_lines, report_title) + + @staticmethod + def test_connection( + token: str = None, + raise_on_exception: bool = True, + ) -> Connection: + """Test connection to Linode. + + Args: + token: Linode Personal Access Token. + raise_on_exception: Flag indicating whether to raise an exception if the connection fails. + + Returns: + Connection: Connection object with is_connected status. + """ + try: + session = LinodeProvider.setup_session(token=token) + # Validate by fetching profile + session.client.profile() + return Connection(is_connected=True) + except LinodeCredentialsError as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise + return Connection(is_connected=False, error=error) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + if raise_on_exception: + raise LinodeAuthenticationError( + file=os.path.basename(__file__), + original_exception=error, + ) + return Connection(is_connected=False, error=error) diff --git a/prowler/providers/linode/models.py b/prowler/providers/linode/models.py new file mode 100644 index 0000000000..8e41e881a7 --- /dev/null +++ b/prowler/providers/linode/models.py @@ -0,0 +1,38 @@ +from typing import Any, Optional + +from pydantic import BaseModel + +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class LinodeSession(BaseModel): + """Linode session information.""" + + client: Any + token: Optional[str] = None + + +class LinodeIdentityInfo(BaseModel): + """Linode identity and scoping information.""" + + username: Optional[str] = None + email: Optional[str] = None + account_id: Optional[str] = None + + +class LinodeOutputOptions(ProviderOutputOptions): + """Customize output filenames for Linode scans.""" + + def __init__(self, arguments, bulk_checks_metadata, identity: LinodeIdentityInfo): + super().__init__(arguments, bulk_checks_metadata) + if ( + not hasattr(arguments, "output_filename") + or arguments.output_filename is None + ): + account_fragment = identity.account_id or identity.username or "linode" + self.output_filename = ( + f"prowler-output-{account_fragment}-{output_file_timestamp}" + ) + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/linode/services/__init__.py b/prowler/providers/linode/services/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/__init__.py b/prowler/providers/linode/services/administration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/administration_client.py b/prowler/providers/linode/services/administration/administration_client.py new file mode 100644 index 0000000000..99a242e9ee --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.administration.administration_service import ( + AdministrationService, +) + +administration_client = AdministrationService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/administration/administration_service.py b/prowler/providers/linode/services/administration/administration_service.py new file mode 100644 index 0000000000..7704cf11b2 --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_service.py @@ -0,0 +1,46 @@ +from typing import List + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class User(BaseModel): + """Model for a Linode account user.""" + + username: str + email: str = "" + tfa_enabled: bool = False + restricted: bool = False + + +class AdministrationService(LinodeService): + """Service to interact with Linode Account Users.""" + + def __init__(self, provider): + super().__init__("administration", provider) + self.users: List[User] = [] + self._describe_users() + + def _describe_users(self): + """Fetch all Linode account users.""" + try: + raw_users = self.client.account.users() + for user in raw_users: + try: + self.users.append( + User( + username=getattr(user, "username", "") or "", + email=getattr(user, "email", "") or "", + tfa_enabled=getattr(user, "tfa_enabled", False) or False, + restricted=getattr(user, "restricted", False) or False, + ) + ) + except Exception as error: + logger.error( + f"account - Error processing user {getattr(user, 'username', 'unknown')}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("account users", "account:read_only", error) diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json new file mode 100644 index 0000000000..c16e26f56d --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "linode", + "CheckID": "administration_user_2fa_enabled", + "CheckTitle": "Linode account user has two-factor authentication enabled", + "CheckType": [], + "ServiceName": "administration", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**Linode account users** are assessed for **two-factor authentication (2FA)** enablement. 2FA adds a second verification factor (a TOTP code or security key) on top of the password, so a stolen or guessed password alone is not enough to sign in to the **Linode Cloud Manager**.", + "Risk": "Without **2FA**, a single compromised password — through phishing, credential stuffing, or brute force — grants an attacker full access to the user's Linode account. This can lead to **data exfiltration**, destruction of instances and backups, and resource abuse for **cryptomining**, impacting **confidentiality**, **integrity**, and **availability**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-2fa" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to My Profile > Login & Authentication\n3. Under Security Settings, configure all 3 security questions\n4. Enable Two-Factor Authentication", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `2FA` for all Linode account users. Use an authenticator app (`TOTP`) for the second factor and store backup codes securely.", + "Url": "https://hub.prowler.com/check/administration_user_2fa_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py new file mode 100644 index 0000000000..2bb2c92729 --- /dev/null +++ b/prowler/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.administration.administration_client import ( + administration_client, +) + + +class administration_user_2fa_enabled(Check): + """Check if Linode account users have two-factor authentication enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the administration_user_2fa_enabled check. + + Iterates over all account users and checks whether two-factor + authentication is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each user. + """ + findings = [] + + for user in administration_client.users: + report = CheckReportLinode( + metadata=self.metadata(), + resource=user, + resource_name=user.username, + resource_id=user.username, + region="global", + ) + + if user.tfa_enabled: + report.status = "PASS" + report.status_extended = ( + f"User '{user.username}' has two-factor authentication enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"User '{user.username}' does not have two-factor authentication enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/__init__.py b/prowler/providers/linode/services/compute/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_client.py b/prowler/providers/linode/services/compute/compute_client.py new file mode 100644 index 0000000000..8dc03fa36d --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_client.py @@ -0,0 +1,4 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.compute.compute_service import ComputeService + +compute_client = ComputeService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json new file mode 100644 index 0000000000..b425e5c5b0 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_backups_enabled", + "CheckTitle": "Linode Instance has the Backup service enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for enrollment in the **Linode Backup service**, which performs automatic daily and weekly snapshots of an instance's disks. Without it enabled, there is no managed recovery point to restore from after a data-loss event.", + "Risk": "With the **Backup service** disabled, there is no automated recovery point for the instance. Accidental deletion, **ransomware**, disk corruption, or operator error can result in **permanent data loss** and extended **downtime**, directly impacting **availability** and data **integrity**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/enable-backups", + "https://techdocs.akamai.com/cloud-computing/docs/create-a-compute-instance#configure-additional-options" + ], + "Remediation": { + "Code": { + "CLI": "linode-cli linodes backups-enable ", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Select the Linode instance\n3. Navigate to the Backups tab\n4. Click 'Enable Backups'", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the `Linode Backup service` on this instance to ensure automated recovery points are available. Consider supplementing it with manual snapshots before critical changes.", + "Url": "https://hub.prowler.com/check/compute_instance_backups_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py new file mode 100644 index 0000000000..c7ae903fbf --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_backups_enabled(Check): + """Check if Linode instances have the Backup service enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_backups_enabled check. + + Iterates over all Linode instances and checks whether the Backup + service is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.backups_enabled: + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has the Backup service enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"Instance {instance.label} does not have the Backup service enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json new file mode 100644 index 0000000000..878030dc25 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_disk_encryption_enabled", + "CheckTitle": "Linode Instance has disk encryption enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for **disk encryption** status. Disk encryption protects **data at rest** on the instance's underlying disks, keeping stored data unreadable without the encryption keys even if the physical media is accessed.", + "Risk": "Without **disk encryption**, data at rest on the instance's disks is stored unprotected. This increases the risk of **data exposure** through physical access to decommissioned or stolen storage media, or via certain **hypervisor-level** vulnerabilities, compromising the **confidentiality** of sensitive data.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/local-disk-encryption", + "https://techdocs.akamai.com/cloud-computing/docs/create-a-compute-instance#enable-or-disable-disk-encryption" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Disk encryption must be enabled at instance creation time or by rebuilding the instance\n2. Create a new instance with disk_encryption set to 'enabled'\n3. Migrate data from the unencrypted instance to the new encrypted instance", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `disk encryption` for this instance. Because it cannot be toggled on existing instances, rebuild or migrate to a new instance with encryption enabled.", + "Url": "https://hub.prowler.com/check/compute_instance_disk_encryption_enabled" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py new file mode 100644 index 0000000000..abe364d0b4 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_disk_encryption_enabled(Check): + """Check if Linode instances have disk encryption enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_disk_encryption_enabled check. + + Iterates over all Linode instances and checks whether disk encryption + is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.disk_encryption == "enabled": + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has disk encryption enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Instance {instance.label} does not have disk encryption enabled." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json new file mode 100644 index 0000000000..42e3c8f46f --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "linode", + "CheckID": "compute_instance_watchdog_enabled", + "CheckTitle": "Linode Instance has Watchdog (Lassie) enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "compute", + "Description": "**Linode instances** are assessed for **Watchdog (Lassie)** status. Watchdog is Linode's automatic recovery feature that monitors an instance and reboots it if it powers off unexpectedly, helping maintain availability without manual intervention.", + "Risk": "With **Watchdog (Lassie)** disabled, an instance that crashes or shuts down unexpectedly stays offline until it is manually restarted. This prolongs **downtime**, delays detection of availability incidents, and weakens the **availability** of services running on the instance.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/recover-from-unexpected-shutdowns-with-lassie" + ], + "Remediation": { + "Code": { + "CLI": "linode-cli linodes update --watchdog_enabled true", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Select the Linode instance\n3. Navigate to the Settings tab\n4. Enable the Shutdown Watchdog (Lassie) toggle", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `Watchdog (Lassie)` on this instance to automatically recover from unexpected shutdowns and improve availability protection.", + "Url": "https://hub.prowler.com/check/compute_instance_watchdog_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py new file mode 100644 index 0000000000..498deb970c --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled.py @@ -0,0 +1,40 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.compute.compute_client import compute_client + + +class compute_instance_watchdog_enabled(Check): + """Check if Linode instances have Watchdog (Lassie) enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the compute_instance_watchdog_enabled check. + + Iterates over all Linode instances and checks whether Watchdog + (Lassie) is enabled. + + Returns: + list[CheckReportLinode]: A list of findings for each instance. + """ + findings = [] + + for instance in compute_client.instances: + report = CheckReportLinode( + metadata=self.metadata(), + resource=instance, + resource_name=instance.label, + resource_id=str(instance.id), + region=instance.region, + ) + report.resource_tags = instance.tags + + if instance.watchdog_enabled: + report.status = "PASS" + report.status_extended = ( + f"Instance {instance.label} has Watchdog (Lassie) enabled." + ) + else: + report.status = "FAIL" + report.status_extended = f"Instance {instance.label} does not have Watchdog (Lassie) enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/compute/compute_service.py b/prowler/providers/linode/services/compute/compute_service.py new file mode 100644 index 0000000000..51d38aa834 --- /dev/null +++ b/prowler/providers/linode/services/compute/compute_service.py @@ -0,0 +1,99 @@ +from typing import List + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class Instance(BaseModel): + """Model for a Linode Instance.""" + + id: int + label: str + region: str + status: str + backups_enabled: bool = False + disk_encryption: str = "disabled" # "enabled" or "disabled" + watchdog_enabled: bool = False + tags: List[str] = [] + + +class ComputeService(LinodeService): + """Service to interact with Linode Instances.""" + + def __init__(self, provider): + super().__init__("compute", provider) + self.instances: List[Instance] = [] + self._describe_instances() + + def _describe_instances(self): + """Fetch all Linode instances with firewall and IP details.""" + # Optional --region filter. None scans all regions. Region-less services + # do not call this, so they are always scanned. + regions_filter = getattr(getattr(self, "provider", None), "regions", None) + try: + raw_instances = self.client.linode.instances() + for inst in raw_instances: + try: + region = ( + inst.region.id + if hasattr(inst.region, "id") + else str(inst.region) + ) + if regions_filter and region not in regions_filter: + continue + + # Get backup status + backups_enabled = False + try: + backups = getattr(inst, "backups", None) + if backups: + backups_enabled = getattr(backups, "enabled", False) + except Exception as error: + logger.warning( + f"instance - Unable to fetch backup status for instance " + f"{inst.id}: {error}" + ) + + # Get disk encryption status + disk_encryption = "disabled" + try: + de = getattr(inst, "disk_encryption", None) + if de: + disk_encryption = str(de) + except Exception as error: + logger.warning( + f"instance - Unable to fetch disk encryption status for " + f"instance {inst.id}: {error}" + ) + + # Get watchdog status + watchdog_enabled = False + try: + watchdog_enabled = getattr(inst, "watchdog_enabled", False) + except Exception as error: + logger.warning( + f"instance - Unable to fetch watchdog status for instance " + f"{inst.id}: {error}" + ) + + self.instances.append( + Instance( + id=inst.id, + label=inst.label or f"linode-{inst.id}", + region=region, + status=inst.status or "unknown", + backups_enabled=backups_enabled, + disk_encryption=disk_encryption, + watchdog_enabled=watchdog_enabled, + tags=inst.tags or [], + ) + ) + except Exception as error: + logger.error( + f"instance - Error processing instance {inst.id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("instances", "linodes:read_only", error) diff --git a/prowler/providers/linode/services/networking/__init__.py b/prowler/providers/linode/services/networking/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_client.py b/prowler/providers/linode/services/networking/networking_client.py new file mode 100644 index 0000000000..0f0406048b --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.linode.services.networking.networking_service import ( + NetworkingService, +) + +networking_client = NetworkingService(Provider.get_global_provider()) diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json new file mode 100644 index 0000000000..475601e5b3 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_assigned_to_devices", + "CheckTitle": "Linode Cloud Firewall is assigned to devices", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to verify each one is attached to at least one device, such as a **Linode instance** or **NodeBalancer**. A firewall only filters traffic for the resources it is assigned to, so an unassigned firewall enforces no protection at all.", + "Risk": "An **unassigned firewall** provides no protection to running workloads, leaving their network exposure governed only by default behavior. This commonly signals a **misconfigured control** where operators assume traffic is filtered when it is not, raising the risk of **unauthorized network access** to unprotected instances.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/apply-firewall-rules-to-a-service" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click on either the Linodes or Nodebalancers tab and add those devices to the firewall", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach each firewall to applicable Linode devices, such as `Linodes` or `NodeBalancers`, to enforce intended network controls.", + "Url": "https://hub.prowler.com/check/networking_firewall_assigned_to_devices" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_status_enabled" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py new file mode 100644 index 0000000000..f245dd168e --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices.py @@ -0,0 +1,52 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.lib.logger import logger +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_assigned_to_devices(Check): + """Check if Linode Cloud Firewalls are assigned to at least one device.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_assigned_to_devices check. + + Iterates over all Cloud Firewalls and checks whether each one is + assigned to at least one device. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + # When the device count could not be determined (the devices fetch + # failed) skip the firewall instead of reporting a false FAIL. + if fw.attached_devices_count is None: + logger.warning( + f"firewall - Skipping firewall '{fw.label}' ({fw.id}): " + "device assignment could not be determined." + ) + continue + + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.attached_devices_count > 0: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' is assigned to {fw.attached_devices_count} device(s)." + else: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' is not assigned to any device." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json new file mode 100644 index 0000000000..16f0666f7d --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_default_inbound_policy_drop", + "CheckTitle": "Linode Cloud Firewall default inbound policy is DROP", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** apply a **default inbound policy** to ingress traffic that does not match any explicit rule. This check verifies the default is set to `DROP`, enforcing a **default-deny** posture so that only traffic intentionally permitted by a rule is allowed in.", + "Risk": "When the default inbound policy is `ACCEPT` instead of `DROP`, any traffic not explicitly denied by a rule is permitted. This **default-allow** posture can silently expose services, management ports, and unintended endpoints to the internet, enlarging the attack surface and increasing the risk of **unauthorized access**.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. In the Rules tab, set the default inbound policy to DROP", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default inbound policy to `DROP` and allow only explicitly required inbound traffic.", + "Url": "https://hub.prowler.com/check/networking_firewall_default_inbound_policy_drop" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_inbound_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py new file mode 100644 index 0000000000..2f5c8cb6c3 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_default_inbound_policy_drop(Check): + """Check if Linode Cloud Firewall default inbound policy is DROP.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_default_inbound_policy_drop check. + + Iterates over all Cloud Firewalls and checks whether the default + inbound policy is DROP. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.inbound_policy == "DROP": + report.status = "PASS" + report.status_extended = ( + f"Firewall '{fw.label}' has default inbound policy set to DROP." + ) + else: + report.status = "FAIL" + report.status_extended = f"Firewall '{fw.label}' has default inbound policy set to {fw.inbound_policy}." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json new file mode 100644 index 0000000000..5de3985464 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_default_outbound_policy_drop", + "CheckTitle": "Linode Cloud Firewall default outbound policy is DROP", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** apply a **default outbound policy** to egress traffic that matches no explicit rule. This check verifies the default is set to `DROP`, enforcing **default-deny egress** so that only approved outbound connections are allowed to leave the instance.", + "Risk": "An outbound default of `ACCEPT` permits unrestricted egress from the instance. If a host is compromised, this eases **data exfiltration** and **command-and-control (C2)** communication and lets malware reach arbitrary external endpoints, harming **confidentiality** and enabling lateral movement.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. In the Rules tab, set the default outbound policy to DROP", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the default outbound policy to `DROP` and allow only explicit outbound destinations and services.", + "Url": "https://hub.prowler.com/check/networking_firewall_default_outbound_policy_drop" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_outbound_rules_configured" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py new file mode 100644 index 0000000000..011693aee9 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_default_outbound_policy_drop(Check): + """Check if Linode Cloud Firewall default outbound policy is DROP.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_default_outbound_policy_drop check. + + Iterates over all Cloud Firewalls and checks whether the default + outbound policy is DROP. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.outbound_policy == "DROP": + report.status = "PASS" + report.status_extended = ( + f"Firewall '{fw.label}' has default outbound policy set to DROP." + ) + else: + report.status = "FAIL" + report.status_extended = f"Firewall '{fw.label}' has default outbound policy set to {fw.outbound_policy}." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json new file mode 100644 index 0000000000..5e54dc6241 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_inbound_rules_configured", + "CheckTitle": "Linode Cloud Firewall inbound rules are configured", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to determine whether explicit **inbound rules** are configured. Defined ingress rules express the intended allow-list of sources, ports, and protocols instead of relying solely on the firewall's default policy.", + "Risk": "Without explicit **inbound rules**, ingress filtering depends entirely on the default policy and no intentional allow-list is documented. This makes the firewall's behavior ambiguous and harder to audit, raising the chance of **overly permissive access** or gaps in the protection operators expect.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click Add an Inbound Rule in the Rules tab", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure explicit `inbound rules` to define allowed ingress sources, ports, and protocols and enforce expected security boundaries.", + "Url": "https://hub.prowler.com/check/networking_firewall_inbound_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_default_inbound_policy_drop" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py new file mode 100644 index 0000000000..9eba9ddeb6 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_inbound_rules_configured(Check): + """Check if Linode Cloud Firewall has inbound rules configured.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_inbound_rules_configured check. + + Iterates over all Cloud Firewalls and checks whether at least one + explicit inbound rule is configured. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if len(fw.inbound_rules) == 0: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' has no inbound rules configured." + ) + else: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' has {len(fw.inbound_rules)} inbound rule(s) configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json new file mode 100644 index 0000000000..9bedf854cc --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_outbound_rules_configured", + "CheckTitle": "Linode Cloud Firewall outbound rules are configured", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** are checked to determine whether explicit **outbound rules** are configured. Defined egress rules express which destinations, ports, and protocols an instance is permitted to reach, rather than depending only on the firewall's default policy.", + "Risk": "Without explicit **outbound rules**, egress behavior depends solely on the default policy and no intended destination allow-list exists. Unconstrained or undocumented egress complicates auditing and can enable **data exfiltration** or connections to malicious endpoints if a host is compromised.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/manage-firewall-rules" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Select the associated firewall\n4. Click Add an Outbound Rule in the Rules tab", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure explicit `outbound rules` to control allowed egress destinations, ports, and protocols.", + "Url": "https://hub.prowler.com/check/networking_firewall_outbound_rules_configured" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_default_outbound_policy_drop" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py new file mode 100644 index 0000000000..1d97dca075 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_outbound_rules_configured(Check): + """Check if Linode Cloud Firewall has outbound rules configured.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_outbound_rules_configured check. + + Iterates over all Cloud Firewalls and checks whether at least one + explicit outbound rule is configured. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if len(fw.outbound_rules) == 0: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' has no outbound rules configured." + ) + else: + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' has {len(fw.outbound_rules)} outbound rule(s) configured." + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json new file mode 100644 index 0000000000..dd1e760d4c --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "linode", + "CheckID": "networking_firewall_status_enabled", + "CheckTitle": "Linode Cloud Firewall status is enabled", + "CheckType": [], + "ServiceName": "networking", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "network", + "Description": "**Linode Cloud Firewalls** can be in an `enabled` or `disabled` state. This check verifies the firewall status is `enabled`, because a firewall must be active for its inbound and outbound policies and rules to actually filter network traffic.", + "Risk": "A `disabled` firewall enforces **none** of its configured rules or default policies, leaving attached instances with no network filtering. This can unexpectedly expose management ports and services to the internet, significantly increasing the risk of **unauthorized access** and exploitation.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://techdocs.akamai.com/cloud-computing/docs/update-cloud-firewall-status" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Log in to the Linode Cloud Manager\n2. Navigate to Firewalls\n3. Click on the Enable button from the options corresponding to the firewall whose status you would like to update", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable `Linode Cloud Firewalls` so configured network filtering controls are actively enforced.", + "Url": "https://hub.prowler.com/check/networking_firewall_status_enabled" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "networking_firewall_assigned_to_devices" + ], + "Notes": "" +} diff --git a/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py new file mode 100644 index 0000000000..603a088bd0 --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled.py @@ -0,0 +1,42 @@ +from prowler.lib.check.models import Check, CheckReportLinode +from prowler.providers.linode.services.networking.networking_client import ( + networking_client, +) + + +class networking_firewall_status_enabled(Check): + """Check if Linode Cloud Firewalls are enabled.""" + + def execute(self) -> list[CheckReportLinode]: + """Execute the networking_firewall_status_enabled check. + + Iterates over all Cloud Firewalls and checks whether each one has + an enabled status. + + Returns: + list[CheckReportLinode]: A list of findings for each firewall. + """ + findings = [] + + for fw in networking_client.firewalls: + report = CheckReportLinode( + metadata=self.metadata(), + resource=fw, + resource_name=fw.label, + resource_id=str(fw.id), + region="global", + ) + report.resource_tags = fw.tags + + if fw.status == "enabled": + report.status = "PASS" + report.status_extended = f"Firewall '{fw.label}' is enabled." + else: + report.status = "FAIL" + report.status_extended = ( + f"Firewall '{fw.label}' is not enabled (status: {fw.status})." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/linode/services/networking/networking_service.py b/prowler/providers/linode/services/networking/networking_service.py new file mode 100644 index 0000000000..97f811a98a --- /dev/null +++ b/prowler/providers/linode/services/networking/networking_service.py @@ -0,0 +1,127 @@ +from typing import List, Optional + +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.linode.lib.service.service import LinodeService + + +class FirewallRule(BaseModel): + """Model for a single firewall rule.""" + + protocol: str = "TCP" + ports: str = "" # e.g. "22", "1-65535", "" + addresses_ipv4: List[str] = [] + addresses_ipv6: List[str] = [] + action: str = "ACCEPT" # ACCEPT or DROP + label: str = "" + + +class Firewall(BaseModel): + """Model for a Linode Cloud Firewall.""" + + id: int + label: str + status: str + inbound_rules: List[FirewallRule] = [] + outbound_rules: List[FirewallRule] = [] + inbound_policy: str + outbound_policy: str + # None means the device count could not be determined (fetch failed), as + # opposed to 0 which means the firewall genuinely has no devices attached. + attached_devices_count: Optional[int] = None + tags: List[str] = [] + + +class NetworkingService(LinodeService): + """Service to interact with Linode Cloud Firewalls.""" + + def __init__(self, provider): + super().__init__("networking", provider) + self.firewalls: List[Firewall] = [] + self._describe_firewalls() + + def _describe_firewalls(self): + """Fetch all Linode Cloud Firewalls with their rules.""" + try: + raw_firewalls = self.client.networking.firewalls() + for fw in raw_firewalls: + try: + inbound_rules = [] + outbound_rules = [] + inbound_policy = "" + outbound_policy = "" + attached_devices_count = None + + try: + attached_devices_count = len(fw.devices) + except Exception as error: + logger.warning( + f"firewall - Unable to fetch devices for firewall {fw.id}: {error}" + ) + + try: + # linode_api4 Firewall objects expose rules as a mapped object. + rules = fw.rules + inbound_policy = getattr(rules, "inbound_policy", "") + outbound_policy = getattr(rules, "outbound_policy", "") + inbound = getattr(rules, "inbound", []) + outbound = getattr(rules, "outbound", []) + + for rule in inbound: + addresses = getattr(rule, "addresses", None) + inbound_rules.append( + FirewallRule( + protocol=( + getattr(rule, "protocol", None) or "TCP" + ).upper(), + ports=getattr(rule, "ports", "") or "", + addresses_ipv4=getattr(addresses, "ipv4", []) or [], + addresses_ipv6=getattr(addresses, "ipv6", []) or [], + action=( + getattr(rule, "action", None) or "ACCEPT" + ).upper(), + label=getattr(rule, "label", "") or "", + ) + ) + for rule in outbound: + addresses = getattr(rule, "addresses", None) + outbound_rules.append( + FirewallRule( + protocol=( + getattr(rule, "protocol", None) or "TCP" + ).upper(), + ports=getattr(rule, "ports", "") or "", + addresses_ipv4=getattr(addresses, "ipv4", []) or [], + addresses_ipv6=getattr(addresses, "ipv6", []) or [], + action=( + getattr(rule, "action", None) or "ACCEPT" + ).upper(), + label=getattr(rule, "label", "") or "", + ) + ) + except Exception as error: + logger.warning( + f"firewall - Unable to fetch rules for firewall {fw.id}: {error}" + ) + + self.firewalls.append( + Firewall( + id=fw.id, + label=fw.label or f"firewall-{fw.id}", + status=fw.status or "unknown", + inbound_rules=inbound_rules, + outbound_rules=outbound_rules, + inbound_policy=inbound_policy, + outbound_policy=outbound_policy, + attached_devices_count=attached_devices_count, + tags=fw.tags or [], + ) + ) + except Exception as error: + logger.error( + f"firewall - Error processing firewall {fw.id}: " + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + self._log_fetch_error("firewalls", "firewall:read_only", error) diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json new file mode 100644 index 0000000000..42b3acaeb6 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.metadata.json @@ -0,0 +1,44 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_no_deleted_object_references", + "CheckTitle": "Conditional Access policies must not reference deleted users, groups, or roles", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Every object identifier referenced by any Conditional Access policy under conditions.users (includeUsers, excludeUsers, includeGroups, excludeGroups, includeRoles, excludeRoles) must resolve to an existing Microsoft Entra object. This check audits all Conditional Access policies regardless of state and reports any whose user, group, or role references no longer resolve in the directory.", + "Risk": "When a user, group, or directory role referenced by a Conditional Access policy stops resolving (account or group deleted, role template removed), the reference becomes orphaned. include* references silently shrink the policy's enforcement scope; exclude* references can cause the policy to evaluate unexpectedly. This is a common root cause of MFA-not-applied incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessusers?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/group-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/unifiedroledefinition-get?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Navigate to Protection > Conditional Access > Policies\n3. Open each policy reported by this check\n4. Under Assignments > Users, remove every user, group, or role identifier reported as deleted\n5. Save the policy and re-run the audit\n6. For ongoing hygiene, audit Conditional Access policies after any bulk user/group/role cleanup", + "Terraform": "" + }, + "Recommendation": { + "Text": "Audit each Conditional Access policy quarterly and remove references to deleted users, groups, or directory roles. Stale references in include collections silently shrink enforcement scope; stale references in exclude collections can cause policies to behave unexpectedly. Treat both as misconfigurations regardless of policy state.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_no_deleted_object_references" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_directory_sync_account_excluded" + ], + "Notes": "The check runs against all Conditional Access policies regardless of state (enabled, disabled, enabledForReportingButNotEnforced) — stale references in disabled policies are a misconfiguration that becomes live the moment the policy is re-enabled. Only HTTP 404 responses flag an identifier as deleted (FAIL). Transient Graph errors (5xx, throttling, insufficient permissions) are not treated as deletions; a policy whose references could not be resolved for those reasons is reported as MANUAL so it is not silently considered clean." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py new file mode 100644 index 0000000000..e5c5eff389 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references.py @@ -0,0 +1,157 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + CONDITIONAL_ACCESS_SENTINEL_IDS, + ConditionalAccessPolicyState, +) + + +class entra_conditional_access_policy_no_deleted_object_references(Check): + """ + Ensure Conditional Access policies do not reference deleted directory objects. + + Stale references to deleted users, groups, or directory roles silently change + the runtime behavior of a Conditional Access policy: include* references + shrink enforcement scope, exclude* references can change exemption logic. + Either way, the policy stops behaving the way the operator believes it does. + + The directory-object existence check runs once at service init time and is + cached on the entra client. This check reads from that cache and reports any + policy whose users/groups/roles inclusion or exclusion collections name an + identifier that no longer resolves in Microsoft Entra ID. + + Identifiers whose Graph lookup failed with a non-404 error (5xx, throttling, + insufficient permissions) are cached separately: they could not be verified + as present or deleted, so the policy is reported as MANUAL rather than being + silently treated as clean. + + - PASS: The policy references no deleted users, groups, or roles. + - FAIL: The policy references at least one deleted user, group, or role. + - MANUAL: At least one referenced identifier could not be resolved due to a + transient Graph error, so the policy could not be fully evaluated. + """ + + def execute(self) -> list[CheckReportM365]: + findings = [] + unresolved = entra_client.unresolved_directory_object_references + errored = entra_client.errored_directory_object_references + + for policy_id, policy in entra_client.conditional_access_policies.items(): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy_id, + ) + + orphans = self._collect_references_in(policy, unresolved) + unverified = self._collect_references_in(policy, errored) + + if orphans: + # A confirmed deletion takes precedence over unverified ones. + report.status = "FAIL" + report.status_extended = self._format_failure( + policy.display_name, orphans, unverified, policy.state + ) + elif unverified: + # Nothing confirmed deleted, but we could not verify every + # reference — do not claim the policy is clean. + report.status = "MANUAL" + report.status_extended = self._format_manual( + policy.display_name, unverified + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Conditional Access policy {policy.display_name} references no " + f"deleted directory objects." + ) + + findings.append(report) + + return findings + + @staticmethod + def _collect_references_in(policy, id_set): + """Walk the six identifier collections and return references in ``id_set``. + + Args: + policy: The Conditional Access policy to inspect. + id_set: Set of ``(type, id)`` pairs to match references against. + + Returns: + list[tuple[str, str, str]]: ``(type, id, side)`` tuples where + ``type`` is one of ``user|group|role``, ``id`` is the Graph + identifier, and ``side`` is one of ``include|exclude``. + """ + if not policy.conditions or not policy.conditions.user_conditions: + return [] + + uc = policy.conditions.user_conditions + collections = ( + ("user", "include", uc.included_users), + ("user", "exclude", uc.excluded_users), + ("group", "include", uc.included_groups), + ("group", "exclude", uc.excluded_groups), + ("role", "include", uc.included_roles), + ("role", "exclude", uc.excluded_roles), + ) + + matches = [] + for type_, side, identifiers in collections: + for identifier in identifiers: + if identifier in CONDITIONAL_ACCESS_SENTINEL_IDS: + continue + if (type_, identifier) in id_set: + matches.append((type_, identifier, side)) + return matches + + @staticmethod + def _group_by_type(references): + """Group ``(type, id, side)`` references into a deterministic message part.""" + by_type = {"user": [], "group": [], "role": []} + for type_, identifier, side in references: + by_type[type_].append(f"{identifier} ({side})") + + parts = [] + for type_ in ("user", "group", "role"): + if by_type[type_]: + joined = ", ".join(sorted(by_type[type_])) + parts.append(f"{type_}s: {joined}") + return "; ".join(parts) + + @classmethod + def _format_failure(cls, display_name, orphans, unverified, state=None): + # Surface report-only mode explicitly: the stale references are not yet + # enforced, but become live the moment the policy is turned on. + report_only = ( + " The policy is in report-only mode, so these references are not " + "enforced yet but will take effect once it is enabled." + if state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING + else "" + ) + + # If some references also could not be resolved, say so rather than + # implying the rest of the policy is fully clean. + unverified_note = ( + f" Additionally, {len(unverified)} reference(s) could not be verified " + f"due to transient Microsoft Graph errors." + if unverified + else "" + ) + + return ( + f"Conditional Access policy {display_name} references " + f"{len(orphans)} deleted directory object(s) — " + f"{cls._group_by_type(orphans)}.{report_only}{unverified_note}" + ) + + @classmethod + def _format_manual(cls, display_name, unverified): + return ( + f"Conditional Access policy {display_name} could not be fully evaluated: " + f"{len(unverified)} reference(s) could not be resolved due to transient " + f"Microsoft Graph errors (5xx, throttling, or insufficient permissions) — " + f"{cls._group_by_type(unverified)}. Re-run the scan or review the policy " + f"manually." + ) diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/__init__.py b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json new file mode 100644 index 0000000000..0cc14e2965 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.metadata.json @@ -0,0 +1,42 @@ +{ + "Provider": "m365", + "CheckID": "entra_directory_sync_object_takeover_blocked", + "CheckTitle": "Microsoft Entra directory sync must block object takeover (soft- and hard-matching)", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "When on-premises directory synchronization is enabled, both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled must be true. Without these blocks, an attacker who can write to on-premises AD can craft an object that matches a privileged cloud account and take it over.", + "Risk": "An attacker with write access to on-premises Active Directory can create an object whose UPN, SMTP address, or ImmutableID matches an existing cloud-only account (e.g. Global Administrator). When the sync engine processes this object, it merges the on-premises identity into the cloud account, effectively granting the attacker full control of that privileged account.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronization?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/onpremisesdirectorysynchronizationfeature?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-syncservice-features" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Open Microsoft Entra admin center\n2. Navigate to Identity > Hybrid management > Microsoft Entra Connect > Connect Sync\n3. Enable 'Block soft match' and 'Block cloud object takeover through hard match'\n4. Alternatively, use Microsoft Graph API to set both features to true on the onPremisesDirectorySynchronization resource", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable both blockSoftMatchEnabled and blockCloudObjectTakeoverThroughHardMatchEnabled on the on-premises directory synchronization configuration. These should remain enabled permanently except during time-boxed migration windows.", + "Url": "https://hub.prowler.com/check/entra_directory_sync_object_takeover_blocked" + } + }, + "Categories": [ + "identity-access", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_password_hash_sync_enabled", + "entra_seamless_sso_disabled" + ], + "Notes": "This check only applies to hybrid tenants with on-premises directory synchronization enabled. Cloud-only tenants receive a PASS since the attack path does not exist." +} diff --git a/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py new file mode 100644 index 0000000000..d1f00a70ff --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked.py @@ -0,0 +1,118 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client + + +class entra_directory_sync_object_takeover_blocked(Check): + """Check that directory sync blocks object takeover via soft-match and hard-match. + + When on-premises directory synchronization is enabled, an attacker who can + write to on-premises AD can craft an object that matches a privileged cloud + account and take it over. Both blockSoftMatchEnabled and + blockCloudObjectTakeoverThroughHardMatchEnabled must be true to prevent this. + + The attack path only exists on hybrid tenants, so the tenant's + organization.onPremisesSyncEnabled is evaluated first. Microsoft Graph + returns an onPremisesSynchronization object (with all features disabled) even + for cloud-only tenants, so the directory sync features must not be evaluated + unless on-premises synchronization is actually enabled. + + - PASS: The tenant is cloud-only, or both block flags are enabled. + - FAIL: On-premises sync is enabled and either block flag is disabled. + - MANUAL: On-premises sync is enabled but the settings cannot be read + (insufficient permissions) or were not returned by Microsoft Graph. + """ + + def execute(self) -> List[CheckReportM365]: + findings = [] + + organizations = entra_client.organizations or [] + on_premises_sync_enabled = any( + organization.on_premises_sync_enabled for organization in organizations + ) + + # Cloud-only tenant: the object takeover attack path does not exist, so + # the directory sync features are not evaluated even if Microsoft Graph + # returns an (all-disabled) onPremisesSynchronization object. + if organizations and not on_premises_sync_enabled: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "PASS" + report.status_extended = ( + f"Entra organization {organization.name} is cloud-only " + "(no on-premises sync), object takeover protection is not " + "applicable." + ) + findings.append(report) + return findings + + # Hybrid tenant but the directory sync settings could not be read. + if entra_client.directory_sync_error: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Cannot verify object takeover protection for " + f"{organization.name}: {entra_client.directory_sync_error}." + ) + findings.append(report) + return findings + + for sync_settings in entra_client.directory_sync_settings: + report = CheckReportM365( + self.metadata(), + resource=sync_settings, + resource_id=sync_settings.id, + resource_name=f"Directory Sync {sync_settings.id}", + ) + + disabled_flags = [] + if not sync_settings.block_soft_match_enabled: + disabled_flags.append("blockSoftMatchEnabled") + if not sync_settings.block_cloud_object_takeover_through_hard_match_enabled: + disabled_flags.append("blockCloudObjectTakeoverThroughHardMatchEnabled") + + if not disabled_flags: + report.status = "PASS" + report.status_extended = ( + f"Entra directory sync {sync_settings.id} blocks both soft-match " + "and hard-match object takeover." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Entra directory sync {sync_settings.id} does not block object " + f"takeover: {', '.join(disabled_flags)} disabled." + ) + + findings.append(report) + + # Hybrid tenant that reported on-premises sync but returned no settings. + if not entra_client.directory_sync_settings: + for organization in organizations: + report = CheckReportM365( + self.metadata(), + resource=organization, + resource_id=organization.id, + resource_name=organization.name, + ) + report.status = "MANUAL" + report.status_extended = ( + f"Entra organization {organization.name} has on-premises sync " + "enabled, but no directory sync settings were returned. Review " + "the tenant configuration manually." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 20f2dbf3a3..bbaa80edcc 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -3,7 +3,7 @@ import json from asyncio import gather from datetime import datetime, timezone from enum import Enum -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from uuid import UUID from kiota_abstractions.base_request_configuration import RequestConfiguration @@ -18,6 +18,12 @@ from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service from prowler.providers.m365.m365_provider import M365Provider +# Sentinel identifiers used in Conditional Access ``conditions.users`` +# collections that do not correspond to real directory objects and must not be +# resolved against Graph. Shared by the resolver below and the check that reads +# its result. +CONDITIONAL_ACCESS_SENTINEL_IDS = {"All", "None", "GuestsOrExternalUsers"} + class Entra(M365Service): """ @@ -110,6 +116,23 @@ class Entra(M365Service): self.app_registrations: Dict[str, "AppRegistration"] = attributes[11] self.user_accounts_status = {} + # Resolve directory-object identifiers referenced by Conditional Access + # policies. This runs as a separate phase because it depends on the + # main gather having populated ``conditional_access_policies`` first. + # The result is cached on the instance so sync checks can read it + # without issuing Graph calls of their own. ``unresolved`` holds ids + # confirmed deleted (HTTP 404); ``errored`` holds ids whose lookup + # failed for any other reason (5xx, throttling, permission) and could + # therefore be neither confirmed present nor confirmed deleted. + self.unresolved_directory_object_references: Set[Tuple[str, str]] + self.errored_directory_object_references: Set[Tuple[str, str]] + ( + self.unresolved_directory_object_references, + self.errored_directory_object_references, + ) = loop.run_until_complete( + self._resolve_directory_object_references(self.conditional_access_policies) + ) + if created_loop: asyncio.set_event_loop(None) loop.close() @@ -791,6 +814,16 @@ class Entra(M365Service): features, "seamless_sso_enabled", False ) or False, + block_soft_match_enabled=getattr( + features, "block_soft_match_enabled", False + ) + or False, + block_cloud_object_takeover_through_hard_match_enabled=getattr( + features, + "block_cloud_object_takeover_through_hard_match_enabled", + False, + ) + or False, ) ) except ODataError as error: @@ -1369,6 +1402,130 @@ OAuthAppInfo ) return app_registrations + async def _resolve_directory_object_references( + self, + policies: Dict[str, "ConditionalAccessPolicy"], + ) -> Tuple[Set[Tuple[str, str]], Set[Tuple[str, str]]]: + """Resolve every user/group/role identifier referenced by CA policies. + + Walks the inclusion/exclusion collections of every loaded Conditional + Access policy, deduplicates the resulting identifiers per type, and + queries Microsoft Graph for each one. Identifiers that return HTTP 404 + are reported as deleted. Non-404 errors (5xx, throttling, permission, + transient network failures) are reported as unresolvable: they must not + be flagged as deletions, but they must also not be silently treated as + clean resolutions, so the downstream check surfaces them as MANUAL. + + The sentinel values ``All``, ``None``, and ``GuestsOrExternalUsers`` + are not directory identifiers and are excluded before any Graph call. + + Args: + policies: Conditional Access policies keyed by policy ID. + + Returns: + Tuple[Set[Tuple[str, str]], Set[Tuple[str, str]]]: A pair of + ``(type, identifier)`` sets. The first holds identifiers that + failed to resolve via Graph with HTTP 404 (deleted); the second + holds identifiers whose lookup failed for any other reason and + could be neither confirmed present nor confirmed deleted. + """ + logger.info( + "Entra - Resolving directory-object references in Conditional " + "Access policies..." + ) + + ids_by_type: Dict[str, Set[str]] = { + "user": set(), + "group": set(), + "role": set(), + } + + for policy in policies.values(): + if not getattr(policy, "conditions", None): + continue + user_conditions = getattr(policy.conditions, "user_conditions", None) + if user_conditions is None: + continue + for ident in (user_conditions.included_users or []) + ( + user_conditions.excluded_users or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["user"].add(ident) + for ident in (user_conditions.included_groups or []) + ( + user_conditions.excluded_groups or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["group"].add(ident) + for ident in (user_conditions.included_roles or []) + ( + user_conditions.excluded_roles or [] + ): + if ident and ident not in CONDITIONAL_ACCESS_SENTINEL_IDS: + ids_by_type["role"].add(ident) + + unresolved: Set[Tuple[str, str]] = set() + errored: Set[Tuple[str, str]] = set() + + # Resolve types in parallel; within a type, walk identifiers serially + # to keep concurrent Graph calls bounded and avoid throttling. + await gather( + self._resolve_identifiers_for_type( + "user", ids_by_type["user"], unresolved, errored + ), + self._resolve_identifiers_for_type( + "group", ids_by_type["group"], unresolved, errored + ), + self._resolve_identifiers_for_type( + "role", ids_by_type["role"], unresolved, errored + ), + ) + return unresolved, errored + + async def _resolve_identifiers_for_type( + self, + type_: str, + identifiers: Set[str], + unresolved: Set[Tuple[str, str]], + errored: Set[Tuple[str, str]], + ) -> None: + """Resolve a set of identifiers of a given type, mutating the result sets. + + Only HTTP 404 (or ``Request_ResourceNotFound``) responses add to + ``unresolved``. Every other error (5xx, throttling, permission, or an + unexpected exception) is logged and added to ``errored`` so the check + can report it as unverified instead of silently treating it as clean. + """ + for identifier in identifiers: + try: + if type_ == "user": + await self.client.users.by_user_id(identifier).get() + elif type_ == "group": + await self.client.groups.by_group_id(identifier).get() + elif type_ == "role": + await self.client.role_management.directory.role_definitions.by_unified_role_definition_id( + identifier + ).get() + else: + continue + except ODataError as error: + status_code = getattr(error, "response_status_code", None) + error_code = getattr(error.error, "code", None) if error.error else None + if status_code == 404 or error_code == "Request_ResourceNotFound": + unresolved.add((type_, identifier)) + else: + errored.add((type_, identifier)) + logger.warning( + f"Entra - Could not resolve {type_} '{identifier}' for " + f"Conditional Access reference check: " + f"{error.__class__.__name__}: {error}" + ) + except Exception as error: + errored.add((type_, identifier)) + logger.warning( + f"Entra - Unexpected error resolving {type_} '{identifier}' " + f"for Conditional Access reference check: " + f"{error.__class__.__name__}: {error}" + ) + class ConditionalAccessPolicyState(Enum): ENABLED = "enabled" @@ -1637,6 +1794,8 @@ class DirectorySyncSettings(BaseModel): id: str password_sync_enabled: bool = False seamless_sso_enabled: bool = False + block_soft_match_enabled: bool = False + block_cloud_object_takeover_through_hard_match_enabled: bool = False class AuthenticationMethodConfiguration(BaseModel): diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/__init__.py b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json new file mode 100644 index 0000000000..4e8f905658 --- /dev/null +++ b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "oraclecloud", + "CheckID": "identity_storage_service_level_admins_scoped", + "CheckTitle": "OCI IAM storage service-level admin policies exclude delete permissions", + "CheckType": [], + "ServiceName": "identity", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "Policy", + "ResourceGroup": "IAM", + "Description": "**OCI IAM policies** are reviewed to ensure storage service-level administrator statements that grant `manage` permissions exclude the relevant storage delete permissions with `request.permission`. This supports CIS OCI 3.1 control 1.15 separation of duties for Block Volume, File Storage, and Object Storage administrators.", + "Risk": "Storage service-level administrators with unrestricted `manage` permissions can delete the resources they administer, including volumes, backups, file systems, mount targets, export sets, objects, or buckets. This weakens separation of duties and can lead to data loss, service disruption, or destructive insider activity.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.oracle.com/en-us/iaas/Content/Identity/policyreference/policyreference.htm", + "https://docs.oracle.com/en-us/iaas/Content/Block/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/File/home.htm", + "https://docs.oracle.com/en-us/iaas/Content/Object/home.htm" + ], + "Remediation": { + "Code": { + "CLI": "oci iam policy update --policy-id --statements \"[\\\"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\\\"]\"", + "NativeIaC": "", + "Other": "1. In OCI Console, go to Identity & Security > Policies\n2. Open each active policy that grants storage service-level administrators `manage` permissions\n3. Edit storage manage statements to exclude the relevant delete permission with `request.permission`\n4. Example: Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\n5. Save changes", + "Terraform": "```hcl\nresource \"oci_identity_policy\" \"storage_admins\" {\n compartment_id = var.compartment_id\n name = \"storage-admins\"\n description = \"Storage administrators without delete permissions\"\n\n statements = [\n \"Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'\",\n \"Allow group VolumeUsers to manage volume-backups in tenancy where request.permission!='VOLUME_BACKUP_DELETE'\",\n \"Allow group FileUsers to manage file-systems in tenancy where request.permission!='FILE_SYSTEM_DELETE'\",\n \"Allow group FileUsers to manage mount-targets in tenancy where request.permission!='MOUNT_TARGET_DELETE'\",\n \"Allow group FileUsers to manage export-sets in tenancy where request.permission!='EXPORT_SET_DELETE'\",\n \"Allow group BucketUsers to manage objects in tenancy where request.permission!='OBJECT_DELETE'\",\n \"Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE'\"\n ]\n}\n```" + }, + "Recommendation": { + "Text": "Exclude delete permissions from storage service-level administrator policies. Use `request.permission!='VOLUME_DELETE'`, `request.permission!='VOLUME_BACKUP_DELETE'`, `request.permission!='FILE_SYSTEM_DELETE'`, `request.permission!='MOUNT_TARGET_DELETE'`, `request.permission!='EXPORT_SET_DELETE'`, `request.permission!='OBJECT_DELETE'`, and `request.permission!='BUCKET_DELETE'` as appropriate for each storage manage statement.", + "Url": "https://hub.prowler.com/check/identity_storage_service_level_admins_scoped" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py new file mode 100644 index 0000000000..49ef16748a --- /dev/null +++ b/prowler/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped.py @@ -0,0 +1,176 @@ +"""Check storage service-level administrators cannot delete managed resources.""" + +import re + +from prowler.lib.check.models import Check, Check_Report_OCI +from prowler.providers.oraclecloud.services.identity.identity_client import ( + identity_client, +) + +STORAGE_DELETE_PERMISSIONS_BY_RESOURCE = { + "volumes": {"VOLUME_DELETE"}, + "volume-backups": {"VOLUME_BACKUP_DELETE"}, + "file-systems": {"FILE_SYSTEM_DELETE"}, + "mount-targets": {"MOUNT_TARGET_DELETE"}, + "export-sets": {"EXPORT_SET_DELETE"}, + "objects": {"OBJECT_DELETE"}, + "buckets": {"BUCKET_DELETE"}, + "volume-family": {"VOLUME_DELETE", "VOLUME_BACKUP_DELETE"}, + "file-family": {"FILE_SYSTEM_DELETE", "MOUNT_TARGET_DELETE", "EXPORT_SET_DELETE"}, + "object-family": {"OBJECT_DELETE", "BUCKET_DELETE"}, +} +ALL_STORAGE_DELETE_PERMISSIONS = set().union( + *STORAGE_DELETE_PERMISSIONS_BY_RESOURCE.values() +) +STORAGE_DELETE_PERMISSIONS_BY_RESOURCE["all-resources"] = ALL_STORAGE_DELETE_PERMISSIONS + +MANAGE_STATEMENT_PATTERN = re.compile( + r"\ballow\s+group\b.+?\bto\s+manage\s+(?P[a-z-]+)\b", + re.IGNORECASE, +) +QUOTED_LITERAL_PATTERN = re.compile(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"") + + +def _normalize_statement(statement: str) -> str: + """Collapse whitespace in an OCI policy statement.""" + return " ".join(statement.strip().split()) + + +def _has_disjunctive_condition(statement: str) -> bool: + """Return True when the WHERE condition can allow alternate branches.""" + condition = re.split(r"\bwhere\b", statement, flags=re.IGNORECASE, maxsplit=1) + if len(condition) != 2: + return False + + condition_without_literals = QUOTED_LITERAL_PATTERN.sub("", condition[1]) + return bool( + re.search(r"\b(any|or)\b|\|\|", condition_without_literals, re.IGNORECASE) + ) + + +def _storage_manage_resource(statement: str) -> str | None: + """Return the managed storage resource in a policy statement, if any.""" + normalized_statement = _normalize_statement(statement) + match = MANAGE_STATEMENT_PATTERN.search(normalized_statement) + if not match: + return None + + resource = match.group("resource").lower() + if resource not in STORAGE_DELETE_PERMISSIONS_BY_RESOURCE: + return None + + return resource + + +def _excluded_permissions(statement: str) -> set[str]: + """Return delete permissions explicitly excluded with request.permission != value.""" + if _has_disjunctive_condition(statement): + return set() + + exclusions = set() + for permission in ALL_STORAGE_DELETE_PERMISSIONS: + pattern = re.compile( + rf"\brequest\.permission\s*!=\s*['\"]?{re.escape(permission)}['\"]?\b", + re.IGNORECASE, + ) + if pattern.search(statement): + exclusions.add(permission) + return exclusions + + +def _missing_delete_exclusions(statement: str) -> tuple[str, set[str]] | None: + """Return the storage resource and missing delete exclusions for a statement.""" + normalized_statement = _normalize_statement(statement) + resource = _storage_manage_resource(normalized_statement) + if not resource: + return None + + required_permissions = STORAGE_DELETE_PERMISSIONS_BY_RESOURCE[resource] + + excluded_permissions = _excluded_permissions(normalized_statement) + missing_permissions = required_permissions - excluded_permissions + if not missing_permissions: + return None + + return resource, missing_permissions + + +class identity_storage_service_level_admins_scoped(Check): + """Ensure storage service-level admins cannot delete resources they manage.""" + + def execute(self) -> list[Check_Report_OCI]: + """Execute the storage service-level administrators scoped check. + + Returns: + A list of OCI check reports for active non-tenant-admin policies. + """ + findings = [] + + for policy in identity_client.policies: + if policy.lifecycle_state != "ACTIVE": + continue + + if policy.name.upper() == "TENANT ADMIN POLICY": + continue + + region = policy.region if hasattr(policy, "region") else "global" + violations = [] + has_storage_manage_statement = False + + for statement in policy.statements: + if _storage_manage_resource(statement): + has_storage_manage_statement = True + + missing_result = _missing_delete_exclusions(statement) + if not missing_result: + continue + + resource, missing_permissions = missing_result + violations.append( + f"statement `{_normalize_statement(statement)}` manages {resource} without excluding: {', '.join(sorted(missing_permissions))}" + ) + + if not has_storage_manage_statement: + continue + + report = Check_Report_OCI( + metadata=self.metadata(), + resource=policy, + region=region, + resource_id=policy.id, + resource_name=policy.name, + compartment_id=policy.compartment_id, + ) + + if violations: + report.status = "FAIL" + report.status_extended = ( + f"Policy '{policy.name}' allows storage service-level administrators to manage storage resources without explicitly excluding required delete permissions: " + + "; ".join(violations) + + "." + ) + else: + report.status = "PASS" + report.status_extended = f"Policy '{policy.name}' excludes required storage delete permissions from storage manage statements." + + findings.append(report) + + if not findings: + region = ( + identity_client.audited_regions[0].key + if identity_client.audited_regions + else "global" + ) + report = Check_Report_OCI( + metadata=self.metadata(), + resource={}, + region=region, + resource_id=identity_client.audited_tenancy, + resource_name="Tenancy", + compartment_id=identity_client.audited_tenancy, + ) + report.status = "PASS" + report.status_extended = "No active storage service-level administrator policies grant manage permissions without excluding delete permissions." + findings.append(report) + + return findings diff --git a/prowler/providers/stackit/services/objectstorage/__init__.py b/prowler/providers/stackit/services/objectstorage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json new file mode 100644 index 0000000000..38da16c5fa --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_access_key_expiration", + "CheckTitle": "ObjectStorage access keys should have an expiration date", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "**ObjectStorage access keys** should have an explicit expiration date. Long-lived credentials increase the blast radius of a credential compromise because they cannot expire on their own. Setting an expiration date enforces periodic rotation and limits the exposure window if a key is leaked.", + "Risk": "If an **ObjectStorage access key** is leaked, stolen, or forgotten without an expiration date, it remains usable indefinitely. An attacker can retain persistent access to object storage resources until the key is manually revoked.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/create-and-delete-object-storage-credentials/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. In the STACKIT Portal navigate to Object Storage > Access Keys. 2. Delete the non-expiring access key. 3. Create a new access key with an expiration date appropriate for your rotation policy (e.g. 90 days). 4. Update all applications and services that use the old key with the new credentials.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage access keys** with an explicit expiration date and establish a rotation process. Delete non-expiring keys and replace them with time-limited credentials. A rotation period of **90 days or less** is recommended.", + "Url": "https://hub.prowler.com/check/objectstorage_access_key_expiration" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Access keys are scoped to credentials groups. This check evaluates all access keys across all credentials groups in the project." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py new file mode 100644 index 0000000000..2fb49ac725 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration.py @@ -0,0 +1,27 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_access_key_expiration(Check): + def execute(self): + findings = [] + for key in objectstorage_client.access_keys: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=key, + ) + report.resource_id = key.key_id + report.resource_name = key.display_name + report.location = key.region + + if key.has_expiration(): + report.status = "PASS" + report.status_extended = f"Access key {key.display_name} has an expiration date set ({key.expires})." + else: + report.status = "FAIL" + report.status_extended = f"Access key {key.display_name} has no expiration date and never rotates." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json new file mode 100644 index 0000000000..cd587c72da --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_object_lock_enabled", + "CheckTitle": "ObjectStorage buckets should have S3 Object Lock enabled", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "**S3 Object Lock** prevents objects from being deleted or overwritten for a fixed period or indefinitely. Enabling it protects against accidental deletion and ransomware by enforcing a **write-once-read-many (WORM)** model. Object Lock can only be enabled when the bucket is created.", + "Risk": "Without **Object Lock**, objects can be deleted or overwritten at any time, increasing the risk of data loss from accidental deletion, malicious actors, or ransomware. Backups and compliance data are particularly vulnerable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-bucket/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Object Lock must be enabled at bucket creation time and cannot be enabled on an existing bucket. Create a new bucket with Object Lock enabled and migrate your data to it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create **ObjectStorage buckets** with S3 Object Lock enabled for workloads that require data immutability, compliance archiving, or ransomware protection. Object Lock cannot be retroactively enabled on existing buckets.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_object_lock_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Object Lock can only be activated at bucket creation. Buckets without Object Lock are not necessarily misconfigured — evaluate based on the sensitivity and compliance requirements of the stored data." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py new file mode 100644 index 0000000000..89a3c1d3fb --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_object_lock_enabled(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.object_lock_enabled: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has S3 Object Lock enabled." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Bucket {bucket.name} does not have S3 Object Lock enabled." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json new file mode 100644 index 0000000000..44088e5889 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.metadata.json @@ -0,0 +1,39 @@ +{ + "Provider": "stackit", + "CheckID": "objectstorage_bucket_retention_policy", + "CheckTitle": "ObjectStorage buckets should have a default retention policy configured", + "CheckType": [], + "ServiceName": "objectstorage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "storage", + "Description": "An **ObjectStorage default retention policy** automatically applies a minimum retention period to every object uploaded to the bucket, preventing deletion or overwriting before the period expires. Without it, objects can be removed immediately after upload, undermining compliance and data durability requirements.", + "Risk": "Buckets without a **default retention policy** offer no automatic protection against premature object deletion. Compliance data, audit logs, and backups may be deleted before their required retention period elapses.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.stackit.cloud/products/storage/object-storage/", + "https://docs.stackit.cloud/products/storage/object-storage/how-tos/object-lock-default-retention/" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "Use the STACKIT Object Storage API or Portal to set a default retention policy on the bucket. Choose COMPLIANCE mode for strict immutability or GOVERNANCE mode to allow privileged users to override the policy.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a **default retention policy** on every bucket that stores compliance-relevant or sensitive data. Choose `COMPLIANCE` mode for regulatory requirements and `GOVERNANCE` mode when administrative overrides are acceptable.", + "Url": "https://hub.prowler.com/check/objectstorage_bucket_retention_policy" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "objectstorage_bucket_object_lock_enabled" + ], + "Notes": "A default retention policy requires Object Lock to be enabled on the bucket. Buckets without Object Lock cannot have a retention policy." +} diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py new file mode 100644 index 0000000000..a9dbc7ed2b --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy.py @@ -0,0 +1,30 @@ +from prowler.lib.check.models import Check, CheckReportStackIT +from prowler.providers.stackit.services.objectstorage.objectstorage_client import ( + objectstorage_client, +) + + +class objectstorage_bucket_retention_policy(Check): + def execute(self): + findings = [] + for bucket in objectstorage_client.buckets: + report = CheckReportStackIT( + metadata=self.metadata(), + resource=bucket, + ) + report.resource_id = bucket.name + report.resource_name = bucket.name + report.location = bucket.region + + if bucket.retention_days and bucket.retention_days > 0: + report.status = "PASS" + report.status_extended = ( + f"Bucket {bucket.name} has a default retention policy of " + f"{bucket.retention_days} day(s) in {bucket.retention_mode} mode." + ) + else: + report.status = "FAIL" + report.status_extended = f"Bucket {bucket.name} does not have a default retention policy configured." + + findings.append(report) + return findings diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_client.py b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py new file mode 100644 index 0000000000..56cdbfd0bf --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + ObjectStorageService, +) + +objectstorage_client = ObjectStorageService(Provider.get_global_provider()) diff --git a/prowler/providers/stackit/services/objectstorage/objectstorage_service.py b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py new file mode 100644 index 0000000000..e37545ec24 --- /dev/null +++ b/prowler/providers/stackit/services/objectstorage/objectstorage_service.py @@ -0,0 +1,306 @@ +import json +from datetime import datetime, timezone +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.stackit.stackit_provider import StackitProvider, suppress_stderr + + +class ObjectStorageService: + def __init__(self, provider: StackitProvider): + self.provider = provider + self.project_id = provider.identity.project_id + self.regional_clients = provider.generate_regional_clients("objectstorage") + + self.buckets: list[Bucket] = [] + self.access_keys: list[AccessKey] = [] + + self._fetch_all_regions() + + def _fetch_all_regions(self): + for region, client in self.regional_clients.items(): + try: + self._list_buckets(client, region) + self._list_access_keys(client, region) + except Exception as error: + if getattr(error, "status", None) == 404: + logger.info( + f"StackIT project {self.project_id} has no ObjectStorage " + f"presence in region {region}; skipping." + ) + continue + raise + + def _handle_api_call(self, api_function, *args, **kwargs): + try: + with suppress_stderr(): + return api_function(*args, **kwargs) + except Exception as e: + self.provider.handle_api_error(e) + raise + + def _list_buckets(self, client, region: str): + response = self._handle_api_call( + client.list_buckets, project_id=self.project_id, region=region + ) + + buckets_list = getattr(response, "buckets", None) or [] + if isinstance(response, dict): + buckets_list = response.get("buckets", []) + + for bucket_data in buckets_list: + try: + if hasattr(bucket_data, "name"): + name = bucket_data.name + object_lock_enabled = getattr( + bucket_data, "object_lock_enabled", False + ) + elif isinstance(bucket_data, dict): + name = bucket_data.get("name", "") + object_lock_enabled = bucket_data.get("objectLockEnabled", False) + else: + continue + except Exception as e: + logger.error(f"Error processing bucket: {e}") + continue + + retention_days, retention_mode = self._get_default_retention( + client, region, name + ) + + self.buckets.append( + Bucket( + name=name, + region=region, + project_id=self.project_id, + object_lock_enabled=object_lock_enabled, + retention_days=retention_days, + retention_mode=retention_mode, + ) + ) + + logger.info(f"Listed {len(buckets_list)} buckets in {region}") + + def _get_default_retention( + self, client, region: str, bucket_name: str + ) -> tuple[Optional[int], Optional[str]]: + try: + response = self._handle_api_call( + client.get_default_retention, + project_id=self.project_id, + region=region, + bucket_name=bucket_name, + ) + days = getattr(response, "days", None) + mode = getattr(response, "mode", None) + if isinstance(response, dict): + days = response.get("days") + mode = response.get("mode") + return days, str(mode) if mode else None + except Exception as e: + if getattr(e, "status", None) == 404: + return None, None + raise + + def _list_access_keys(self, client, region: str): + credentials_groups_response = self._handle_api_call( + client.list_credentials_groups, project_id=self.project_id, region=region + ) + + credentials_groups = ( + getattr(credentials_groups_response, "credentials_groups", None) or [] + ) + if isinstance(credentials_groups_response, dict): + credentials_groups = credentials_groups_response.get( + "credentialsGroups", + credentials_groups_response.get("credentials_groups", []), + ) + + total_keys = 0 + + for credentials_group_data in credentials_groups: + try: + if isinstance(credentials_group_data, dict): + credentials_group_id = credentials_group_data.get( + "id", + credentials_group_data.get( + "groupId", + credentials_group_data.get("credentialsGroupId", ""), + ), + ) + credentials_group_name = credentials_group_data.get( + "displayName", + credentials_group_data.get("name", credentials_group_id), + ) + else: + credentials_group_id = ( + getattr(credentials_group_data, "id", None) + or getattr(credentials_group_data, "group_id", None) + or getattr(credentials_group_data, "credentials_group_id", "") + ) + credentials_group_name = getattr( + credentials_group_data, + "display_name", + getattr(credentials_group_data, "name", credentials_group_id), + ) + except Exception as e: + logger.error(f"Error processing credentials group: {e}") + continue + + if not credentials_group_id: + continue + + response = self._list_access_keys_response( + client, region, credentials_group_id + ) + keys_list = self._extract_access_keys(response) + + for key_data in keys_list: + try: + if hasattr(key_data, "key_id"): + key_id = key_data.key_id + display_name = getattr(key_data, "display_name", key_id) + expires = getattr(key_data, "expires", None) + elif isinstance(key_data, dict): + key_id = key_data.get("keyId", key_data.get("key_id", "")) + display_name = key_data.get( + "displayName", key_data.get("display_name", key_id) + ) + expires = key_data.get("expires") + else: + continue + + if not key_id: + continue + + self.access_keys.append( + AccessKey( + key_id=key_id, + display_name=display_name, + expires=expires, + region=region, + project_id=self.project_id, + credentials_group_id=credentials_group_id, + credentials_group_name=credentials_group_name, + ) + ) + except Exception as e: + logger.error(f"Error processing access key: {e}") + continue + + total_keys += len(keys_list) + + logger.info(f"Listed {total_keys} access keys in {region}") + + def _list_access_keys_response( + self, client, region: str, credentials_group_id: str + ): + raw_method = None + if callable( + getattr(type(client), "list_access_keys_without_preload_content", None) + ): + raw_method = client.list_access_keys_without_preload_content + elif callable(vars(client).get("list_access_keys_without_preload_content")): + raw_method = vars(client)["list_access_keys_without_preload_content"] + + if raw_method: + response = self._handle_api_call( + raw_method, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + self._raise_for_raw_response_status(response) + return response + + return self._handle_api_call( + client.list_access_keys, + project_id=self.project_id, + region=region, + credentials_group=credentials_group_id, + ) + + def _raise_for_raw_response_status(self, response): + status = getattr(response, "status", None) + if status is None: + status = getattr(response, "status_code", None) + if isinstance(status, int) and status >= 400: + error = Exception( + f"StackIT ObjectStorage list_access_keys failed with status {status}" + ) + error.status = status + self.provider.handle_api_error(error) + raise error + + @staticmethod + def _extract_access_keys(response) -> list: + payload = response + if not isinstance(payload, (dict, list)): + json_method = getattr(response, "json", None) + if callable(json_method): + payload = json_method() + elif hasattr(response, "data"): + payload = ObjectStorageService._parse_raw_json(response.data) + elif hasattr(response, "text"): + payload = ObjectStorageService._parse_raw_json(response.text) + + if isinstance(payload, dict): + return payload.get("accessKeys", payload.get("access_keys", [])) + if isinstance(payload, list): + return payload + return getattr(response, "access_keys", None) or [] + + @staticmethod + def _parse_raw_json(raw): + if raw in (None, b"", ""): + return {} + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + if isinstance(raw, str): + return json.loads(raw) + return raw + + +class Bucket(BaseModel): + name: str + region: str + project_id: str + object_lock_enabled: bool = False + retention_days: Optional[int] = None + retention_mode: Optional[str] = None + + +class AccessKey(BaseModel): + key_id: str + display_name: str + # None or a sentinel year-0001 date string means the key never expires. + expires: Optional[str] = None + region: str + project_id: str + credentials_group_id: Optional[str] = None + credentials_group_name: Optional[str] = None + + def has_expiration(self) -> bool: + """Return True if the key has a real (non-sentinel) expiration date.""" + if not self.expires: + return False + try: + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + # Year 0001 (or earlier) is the SDK sentinel for "never expires" + return dt.year > 1 + except (ValueError, AttributeError): + return False + + def expires_within_days(self, days: int) -> bool: + """Return True if the key expires within the given number of days from now.""" + if not self.has_expiration(): + return False + expires_str = self.expires.replace("Z", "+00:00") + dt = datetime.fromisoformat(expires_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = dt - datetime.now(tz=timezone.utc) + return delta.days <= days diff --git a/prowler/providers/stackit/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py index 417555ef1d..4c34afd31b 100644 --- a/prowler/providers/stackit/stackit_provider.py +++ b/prowler/providers/stackit/stackit_provider.py @@ -15,6 +15,7 @@ from colorama import Style # loader and surfacing as a misleading empty report. from stackit.core.configuration import Configuration from stackit.iaas import DefaultApi as IaasDefaultApi +from stackit.objectstorage import DefaultApi as ObjectStorageDefaultApi from stackit.resourcemanager import DefaultApi as ResourceManagerDefaultApi from prowler.config.config import ( @@ -224,11 +225,17 @@ class StackitProvider(Provider): return json_regions.intersection(audited_regions) return json_regions + _SERVICE_API_CLASS = { + "iaas": IaasDefaultApi, + "objectstorage": ObjectStorageDefaultApi, + } + def generate_regional_clients(self, service: str = "iaas") -> dict: """Generate regional API clients for the given service. Returns dict: {"eu01": DefaultApi_client, "eu02": DefaultApi_client} """ + api_class = self._SERVICE_API_CLASS.get(service, IaasDefaultApi) regional_clients = {} service_regions = self.get_available_service_regions( service, self._audited_regions @@ -240,7 +247,7 @@ class StackitProvider(Provider): self._service_account_key_path, self._service_account_key, ) - client = IaasDefaultApi(config) + client = api_class(config) client.region = region # Attach region attribute regional_clients[region] = client diff --git a/prowler/providers/stackit/stackit_regions_by_service.json b/prowler/providers/stackit/stackit_regions_by_service.json index d9c8cdb9be..5841d4e32d 100644 --- a/prowler/providers/stackit/stackit_regions_by_service.json +++ b/prowler/providers/stackit/stackit_regions_by_service.json @@ -5,6 +5,12 @@ "eu01", "eu02" ] + }, + "objectstorage": { + "regions": [ + "eu01", + "eu02" + ] } } } diff --git a/pyproject.toml b/pyproject.toml index f029f3d6f7..8ad1f6888d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = ["hatchling"] [dependency-groups] dev = [ "bandit==1.8.3", - "black==25.1.0", + "black==26.3.1", "coverage==7.6.12", "docker==7.1.0", "filelock==3.20.3", @@ -17,7 +17,7 @@ dev = [ "openapi-spec-validator==0.7.1", "prek==0.3.9", "pylint==3.3.4", - "pytest==8.3.5", + "pytest==9.0.3", "pytest-cov==6.0.0", "pytest-env==1.1.5", "pytest-randomly==3.16.0", @@ -32,10 +32,10 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] dependencies = [ - "awsipranges==0.3.3", "alive-progress==3.3.0", "azure-identity==1.21.0", "azure-keyvault-keys==4.10.0", @@ -78,10 +78,11 @@ dependencies = [ "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", "kubernetes==32.0.1", + "linode-api4==5.45.0", "markdown==3.10.2", - "microsoft-kiota-abstractions==1.9.2", + "microsoft-kiota-abstractions==1.9.9", + "numpy==2.2.6", "msgraph-sdk==1.55.0", - "numpy==2.0.2", "okta==3.4.2", "openstacksdk==4.2.0", "pandas==2.2.3", @@ -95,11 +96,12 @@ dependencies = [ "slack-sdk==3.39.0", "stackit-core==0.2.0", "stackit-iaas==1.4.0", + "stackit-objectstorage==1.4.0", "stackit-resourcemanager==0.8.0", "tabulate==0.9.0", "tzlocal==5.3.1", "uuid6==2024.7.10", - "py-iam-expand==0.1.0", + "py-iam-expand==0.3.0", "h2==4.3.0", "oci==2.169.0", "alibabacloud_credentials==1.0.3", @@ -122,8 +124,8 @@ license = "Apache-2.0" maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] name = "prowler" readme = "README.md" -requires-python = ">=3.10,<3.13" -version = "5.30.0" +requires-python = ">=3.10,<3.14" +version = "5.31.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -161,7 +163,7 @@ constraint-dependencies = [ "aenum==3.1.17", "aiofiles==24.1.0", "aiohappyeyeballs==2.6.1", - "aiohttp==3.13.5", + "aiohttp==3.14.0", "aiosignal==1.4.0", "alibabacloud-actiontrail20200706==2.4.1", "alibabacloud-credentials==1.0.3", @@ -204,7 +206,7 @@ constraint-dependencies = [ "azure-core==1.41.0", "azure-mgmt-core==1.6.0", "bandit==1.8.3", - "black==25.1.0", + "black==26.3.1", "blinker==1.9.0", "certifi==2026.4.22", "cffi==2.0.0", @@ -217,6 +219,7 @@ constraint-dependencies = [ "coverage==7.6.12", "darabonba-core==1.0.5", "decorator==5.2.1", + "deprecated==1.3.1", "dill==0.4.1", "distro==1.9.0", "dnspython==2.8.0", @@ -266,12 +269,12 @@ constraint-dependencies = [ "markupsafe==3.0.3", "mccabe==0.7.0", "mdurl==0.1.2", - "microsoft-kiota-authentication-azure==1.9.2", - "microsoft-kiota-http==1.9.2", - "microsoft-kiota-serialization-form==1.9.2", - "microsoft-kiota-serialization-json==1.9.2", - "microsoft-kiota-serialization-multipart==1.9.2", - "microsoft-kiota-serialization-text==1.9.2", + "microsoft-kiota-authentication-azure==1.9.9", + "microsoft-kiota-http==1.9.9", + "microsoft-kiota-serialization-form==1.9.9", + "microsoft-kiota-serialization-json==1.9.9", + "microsoft-kiota-serialization-multipart==1.9.9", + "microsoft-kiota-serialization-text==1.9.9", "mock==5.2.0", "moto==5.1.11", "mpmath==1.3.0", @@ -299,6 +302,7 @@ constraint-dependencies = [ "platformdirs==4.9.6", "plotly==6.7.0", "pluggy==1.6.0", + "polling==0.3.2", "prek==0.3.9", "propcache==0.5.2", "proto-plus==1.28.0", @@ -319,11 +323,12 @@ constraint-dependencies = [ "pynacl==1.6.2", "pyopenssl==26.2.0", "pyparsing==3.3.2", - "pytest==8.3.5", + "pytest==9.0.3", "pytest-cov==6.0.0", "pytest-env==1.1.5", "pytest-randomly==3.16.0", "pytest-xdist==3.6.1", + "pytokens==0.4.1", "pywin32==311", "pyyaml==6.0.3", "referencing==0.36.2", @@ -363,3 +368,13 @@ constraint-dependencies = [ "zstd==1.5.7.3" ] override-dependencies = ["okta==3.4.2"] + +[tool.vulture] +# Suppress known false positives. The CI command only passes --exclude and +# --min-confidence on the CLI, so ignore_names from here still applies (vulture +# only overrides the keys set on the CLI). +# - mock_* : pytest fixtures injected as test params but unused in the body +# (e.g. mock_sensitive_args in tests/lib/cli/redact_test.py) +# - view : DRF BasePermission.has_object_permission(self, request, view, obj) +# framework-required signature param in skills/django-drf template assets +ignore_names = ["mock_*", "view"] diff --git a/scripts/development/dev-local.sh b/scripts/development/dev-local.sh new file mode 100755 index 0000000000..d193b81532 --- /dev/null +++ b/scripts/development/dev-local.sh @@ -0,0 +1,558 @@ +#!/usr/bin/env bash +# +# Local dev for Prowler API + worker. +# Postgres / Valkey / Neo4j run in Docker via docker-compose-dev.yml; +# Django and Celery run natively. +# +# Quick start: +# make dev-setup first-time bootstrap (deps, migrations, fixtures) +# make dev launch api + worker + postgres logs in tmux +# make dev-attach attach to the tmux session in the current terminal pane +# make dev-launch use fixed ports and attach interactive local dev +# make dev-stop stop everything: tmux + stop + remove containers +# make dev-clean remove stopped containers only (data preserved) +# make dev-wipe full nuke: kill + clean + delete ./_data/ +# +# Agent / non-interactive usage: 'make dev' is idempotent, runs +# everything detached, blocks until the API answers HTTP, and ends with +# parseable key=value lines (api_url=, api_log=, worker_log=, +# postgres_log=, attach_cmd=, stop_cmd=). +# Every pane is teed ANSI-stripped to _data/logs/{api,worker,postgres}.log +# (truncated per run), so logs are readable with tail -f, no tmux needed. +# +# 'attach' attaches to tmux inside the current terminal pane. +# +# Inside the tmux session "prowler-dev-" the prefix key is Ctrl+b. After it: +# d detach (everything keeps running; reattach: make dev-attach) +# move between panes +# z zoom current pane (toggle) +# [ scrollback mode (q to exit) +# x kill current pane (asks for confirmation) +# & kill current window +# :kill-session end the whole session +# +# How to stop everything from inside tmux: +# 1) Ctrl+b then d detach back to your shell +# 2) make dev-stop tears down tmux + containers +# Alternative without detaching: open a new tmux window with Ctrl+b then c +# and run make dev-stop there; the session will close itself. +# +# Stop just the python procs (keep containers up to skip a slow neo4j boot next time): +# ./scripts/development/dev-local.sh down +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" + +path_hash() { + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$REPO_ROOT" | shasum | awk '{print substr($1, 1, 8)}' + else + printf '%s' "$REPO_ROOT" | cksum | awk '{print $1}' + fi +} + +compose_project_default() { + local base hash + base="$(basename "$REPO_ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9_-]+/-/g; s/^-+//; s/-+$//')" + hash="$(path_hash)" + printf '%s-%s' "${base:-prowler}" "$hash" +} + +export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-$(compose_project_default)}" + +if [ -f .env ]; then + set -a + # shellcheck disable=SC1091 + . ./.env + set +a +fi + +export POSTGRES_HOST=localhost +export POSTGRES_PORT="${POSTGRES_PORT:-5432}" +export VALKEY_HOST=localhost +export VALKEY_PORT="${VALKEY_PORT:-6379}" +export VALKEY_SCHEME="${VALKEY_SCHEME:-redis}" +export NEO4J_HOST=localhost +export NEO4J_PORT="${NEO4J_PORT:-7687}" +export NEO4J_HTTP_PORT="${NEO4J_HTTP_PORT:-7474}" +export DJANGO_SETTINGS_MODULE=config.django.devel +export DJANGO_DEBUG="${DJANGO_DEBUG:-True}" +export DJANGO_PORT="${DJANGO_PORT:-8080}" +export DJANGO_LOGGING_FORMATTER="${DJANGO_LOGGING_FORMATTER:-human_readable}" +export DJANGO_LOGGING_LEVEL="${DJANGO_LOGGING_LEVEL:-info}" +export DJANGO_MANAGE_DB_PARTITIONS="${DJANGO_MANAGE_DB_PARTITIONS:-False}" + +if docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +else + COMPOSE=(docker-compose) +fi +COMPOSE_FILE="docker-compose-dev.yml" + +log() { printf '\033[1;34m→\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m!\033[0m %s\n' "$*"; } + +require_uv() { + if ! command -v uv >/dev/null 2>&1; then + warn "uv not found in PATH. Install: https://docs.astral.sh/uv/getting-started/installation/" + exit 1 + fi +} + +services_up() { + log "Starting postgres + valkey + neo4j via Docker (waits for healthchecks)..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" up -d --wait postgres valkey neo4j + ok "Services ready: pg:${POSTGRES_PORT} / valkey:${VALKEY_PORT} / neo4j:${NEO4J_PORT} / neo4j-http:${NEO4J_HTTP_PORT}" +} + +kill_tmux_session() { + command -v tmux >/dev/null 2>&1 || return 0 + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + log "Killing tmux session '$TMUX_SESSION'" + tmux kill-session -t "$TMUX_SESSION" + fi +} + +tmux_pane_count() { + tmux list-panes -t "$TMUX_SESSION:" 2>/dev/null | wc -l | tr -d ' ' +} + +# Tee a tmux pane's output to _data/logs/.log so non-interactive +# consumers (scripts, agents) can follow the stack without attaching. +# ANSI codes are stripped; the file is truncated on session creation so +# its content always matches the current run. +pipe_pane_log() { + local pane="$1" name="$2" + local logfile="$REPO_ROOT/_data/logs/$name.log" + : > "$logfile" + tmux pipe-pane -t "$pane" -o "perl -pe 'BEGIN{\$|=1} s/\\e\\[[0-9;?]*[A-Za-z]//g' >> '$logfile'" +} + +# Stop native dev processes (api/worker/beat) launched outside tmux. Scoped by +# cwd under REPO_ROOT so other prowler clones running their own stacks are not +# touched. +kill_native_procs() { + command -v pgrep >/dev/null 2>&1 || return 0 + command -v lsof >/dev/null 2>&1 || return 0 + + local pids="" pid pcwd pat + for pat in "manage.py runserver" "celery -A config.celery worker" "celery -A config.celery beat"; do + while IFS= read -r pid; do + [ -n "$pid" ] || continue + pcwd="$(lsof -a -d cwd -p "$pid" 2>/dev/null | awk 'NR>1 {print $NF; exit}')" + case "$pcwd" in + "$REPO_ROOT"|"$REPO_ROOT"/*) pids="$pids $pid" ;; + esac + done < <(pgrep -f "$pat" 2>/dev/null) + done + + pids="$(printf '%s\n' "$pids" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -z "$pids" ] && return 0 + + log "Stopping native dev procs ($pids) under $REPO_ROOT" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 0.5 + # shellcheck disable=SC2086 + kill -KILL $pids 2>/dev/null || true +} + +services_down() { + kill_tmux_session + kill_native_procs + log "Stopping postgres + valkey + neo4j..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" stop postgres valkey neo4j +} + +services_status() { + "${COMPOSE[@]}" -f "$COMPOSE_FILE" ps postgres valkey neo4j +} + +deps() { + require_uv + log "uv sync (api/)..." + (cd api && uv sync) +} + +migrate() { + require_uv + log "Applying migrations (admin DB)..." + ( + cd api/src/backend + uv run python manage.py check_and_fix_socialaccount_sites_migration --database admin + uv run python manage.py migrate --database admin + ) + ok "Migrations applied" +} + +fixtures() { + require_uv + log "Loading dev fixtures..." + ( + cd api/src/backend + for fixture in api/fixtures/dev/*.json; do + [ -f "$fixture" ] || continue + echo " loading $(basename "$fixture")" + uv run python manage.py loaddata "$fixture" --database admin + done + ) + ok "Fixtures loaded" +} + +api_run() { + require_uv + log "Starting Django API on [::]:${DJANGO_PORT} (IPv4 + IPv6 dual-stack)" + cd api/src/backend + exec uv run python manage.py runserver "[::]:${DJANGO_PORT}" +} + +worker_run() { + require_uv + log "Starting Celery worker" + cd api/src/backend + exec uv run python -m celery -A config.celery worker \ + -l "${DJANGO_LOGGING_LEVEL}" \ + -Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \ + -E +} + +beat_run() { + require_uv + log "Starting Celery beat (DatabaseScheduler)" + cd api/src/backend + exec uv run python -m celery -A config.celery beat \ + -l "${DJANGO_LOGGING_LEVEL}" \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler +} + +TMUX_SESSION="prowler-dev-${COMPOSE_PROJECT_NAME}" +SCRIPT_PATH="$REPO_ROOT/scripts/development/dev-local.sh" + +require_tmux() { + if ! command -v tmux >/dev/null 2>&1; then + warn "tmux not installed. Run: brew install tmux" + exit 1 + fi +} + +remove_repo_compose_containers() { + command -v docker >/dev/null 2>&1 || return 0 + local ids + ids="$( + docker ps -aq 2>/dev/null \ + | while IFS= read -r id; do + docker inspect --format '{{.ID}} {{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$id" 2>/dev/null || true + done \ + | awk -v repo="$REPO_ROOT" '$2 == repo {print $1}' + )" + [ -n "$ids" ] || return 0 + warn "Removing existing Docker containers for this repo before launch" + # shellcheck disable=SC2086 + docker rm -f $ids >/dev/null +} + +remove_docker_containers_on_ports() { + command -v docker >/dev/null 2>&1 || return 0 + local all_ids ids_to_remove="" id port published_ports + all_ids="$(docker ps -aq 2>/dev/null || true)" + [ -n "$all_ids" ] || return 0 + + for id in $all_ids; do + published_ports="$(docker inspect --format '{{range $containerPort, $bindings := .HostConfig.PortBindings}}{{range $bindings}}{{.HostPort}}{{"\n"}}{{end}}{{end}}' "$id" 2>/dev/null || true)" + [ -n "$published_ports" ] || continue + for port in "$@"; do + if printf '%s\n' "$published_ports" | grep -qx "$port"; then + ids_to_remove="$ids_to_remove $id" + break + fi + done + done + + ids_to_remove="$(printf '%s\n' "$ids_to_remove" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -n "$ids_to_remove" ] || return 0 + warn "Removing Docker containers publishing fixed dev ports: $ids_to_remove" + # shellcheck disable=SC2086 + docker rm -f $ids_to_remove >/dev/null +} + +kill_listeners_on_ports() { + command -v lsof >/dev/null 2>&1 || return 0 + local pids="" port port_pids + for port in "$@"; do + port_pids="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)" + [ -n "$port_pids" ] && pids="$pids $port_pids" + done + + pids="$(printf '%s\n' "$pids" | tr ' ' '\n' | sed '/^$/d' | sort -u | xargs)" + [ -n "$pids" ] || return 0 + warn "Killing local processes listening on fixed dev ports: $pids" + # shellcheck disable=SC2086 + kill -TERM $pids 2>/dev/null || true + sleep 0.5 + # shellcheck disable=SC2086 + kill -KILL $pids 2>/dev/null || true +} + +clear_dev_port_conflicts() { + local ports=("$DJANGO_PORT" "$POSTGRES_PORT" "$VALKEY_PORT" "$NEO4J_PORT" "$NEO4J_HTTP_PORT") + log "Ensuring fixed dev ports are free: api:${DJANGO_PORT} pg:${POSTGRES_PORT} valkey:${VALKEY_PORT} neo4j:${NEO4J_PORT} neo4j-http:${NEO4J_HTTP_PORT}" + remove_docker_containers_on_ports "${ports[@]}" + kill_listeners_on_ports "${ports[@]}" +} + +# Block until the API answers HTTP on DJANGO_PORT (any status code counts). +wait_for_api() { + local timeout="${1:-90}" waited=0 + log "Waiting for API on :${DJANGO_PORT} (timeout ${timeout}s)..." + until curl -s -o /dev/null --max-time 2 "http://localhost:${DJANGO_PORT}/"; do + waited=$((waited + 1)) + if [ "$waited" -ge "$timeout" ]; then + warn "API not responding after ${timeout}s. Check _data/logs/api.log" + return 1 + fi + sleep 1 + done + ok "API responding on :${DJANGO_PORT}" +} + +needs_db_bootstrap() { + ! "${COMPOSE[@]}" -f "$COMPOSE_FILE" exec -T postgres \ + psql -U prowler -d prowler_db -c 'select 1' >/dev/null 2>&1 +} + +bootstrap_db_if_needed() { + if needs_db_bootstrap; then + warn "App DB user not ready. Bootstrapping (deps + migrate + fixtures)..." + deps + migrate + fixtures + ok "DB bootstrap complete" + fi +} + +all_run() { + require_tmux + require_uv + services_up + bootstrap_db_if_needed + + # Wrap a command so it auto-runs as the pane's foreground process and, on + # exit (e.g. Ctrl+C), drops into the user's login shell instead of closing. + # Avoids the `send-keys` race where keys arrive before zsh+starship are ready. + local user_shell="${SHELL:-/bin/zsh}" + pane_cmd() { + printf 'bash -c %q' "$1; exec ${user_shell}" + } + local api_cmd worker_cmd pg_cmd + api_cmd="$(pane_cmd "$SCRIPT_PATH api")" + worker_cmd="$(pane_cmd "$SCRIPT_PATH worker")" + pg_cmd="$(pane_cmd "${COMPOSE[*]} -f $COMPOSE_FILE logs -f postgres")" + + # If a tmux server is already running (e.g. another project's session), new + # sessions inherit THAT server's env, not the launcher shell's env. Pass our + # overrides explicitly so each pane sees the right project name, ports, etc. + local -a env_args=() + local var + for var in COMPOSE_PROJECT_NAME \ + POSTGRES_HOST POSTGRES_PORT \ + VALKEY_HOST VALKEY_PORT VALKEY_SCHEME \ + NEO4J_HOST NEO4J_PORT NEO4J_HTTP_PORT \ + DJANGO_SETTINGS_MODULE DJANGO_DEBUG DJANGO_PORT \ + DJANGO_LOGGING_FORMATTER DJANGO_LOGGING_LEVEL \ + DJANGO_MANAGE_DB_PARTITIONS; do + env_args+=(-e "${var}=${!var-}") + done + + local expected_panes=3 + + if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + local current_panes + current_panes="$(tmux_pane_count)" + if [ "$current_panes" -lt "$expected_panes" ]; then + warn "Session '$TMUX_SESSION' has $current_panes pane(s), expected $expected_panes. Rebuilding it." + tmux kill-session -t "$TMUX_SESSION" + else + log "Session '$TMUX_SESSION' already exists" + fi + fi + + if ! tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then + log "Creating tmux session '$TMUX_SESSION' (api / worker / db logs)" + tmux new-session -d -s "$TMUX_SESSION" -n services -c "$REPO_ROOT" "${env_args[@]}" "$api_cmd" + tmux split-window -t "$TMUX_SESSION:0.0" -v -p 50 -c "$REPO_ROOT" "${env_args[@]}" "$worker_cmd" + tmux split-window -t "$TMUX_SESSION:0.1" -h -p 50 -c "$REPO_ROOT" "${env_args[@]}" "$pg_cmd" + tmux select-pane -t "$TMUX_SESSION:0.0" + tmux set-option -t "$TMUX_SESSION" -g mouse on >/dev/null + tmux set-option -t "$TMUX_SESSION" -g bell-action none >/dev/null + tmux set-option -t "$TMUX_SESSION" -g visual-bell off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g monitor-bell off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g monitor-activity off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g visual-activity off >/dev/null + tmux set-option -t "$TMUX_SESSION" -g activity-action none >/dev/null + tmux set-option -t "$TMUX_SESSION" -g silence-action none >/dev/null + tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" 2>/dev/null + tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy" 2>/dev/null + tmux set-option -t "$TMUX_SESSION" -g status-right " API:${DJANGO_PORT} | DB:${POSTGRES_PORT} | Broker:${VALKEY_PORT} " >/dev/null + + mkdir -p "$REPO_ROOT/_data/logs" + pipe_pane_log "$TMUX_SESSION:0.0" api + pipe_pane_log "$TMUX_SESSION:0.1" worker + pipe_pane_log "$TMUX_SESSION:0.2" postgres + fi + + wait_for_api 90 + + ok "Dev stack ready (detached)" + cat </dev/null; then + warn "No session '$TMUX_SESSION'. Starting it with: $0 all" + all_run + else + local current_panes + current_panes="$(tmux_pane_count)" + if [ "$current_panes" -lt 3 ]; then + warn "Session '$TMUX_SESSION' only has $current_panes pane(s). Rebuilding with db logs." + tmux kill-session -t "$TMUX_SESSION" + all_run + fi + fi + if [ -n "${TMUX:-}" ]; then + tmux switch-client -t "$TMUX_SESSION" + else + tmux attach-session -t "$TMUX_SESSION" + fi +} + +clean_run() { + log "Removing stopped containers from the compose project..." + "${COMPOSE[@]}" -f "$COMPOSE_FILE" rm -f + ok "Stopped containers removed (data volumes under ./_data/ untouched)" +} + +wipe_run() { + warn "DESTRUCTIVE: this will tear down everything AND delete ./_data/" + warn " postgres database, valkey state, neo4j graph, api jwt keys - all gone." + warn " Next start will require 'make dev-setup' from scratch." + kill_run + if [ -d ./_data ]; then + log "Removing ./_data/ ..." + rm -rf ./_data + fi + ok "Wipe complete. Run 'make dev-setup' for a fresh environment." +} + +kill_run() { + kill_tmux_session + services_down + clean_run + ok "Dev stack stopped (tmux killed, containers stopped + removed)" +} + +launch_run() { + require_uv + kill_tmux_session + remove_repo_compose_containers + clear_dev_port_conflicts + + log "Launching dev stack in tmux inside the current terminal" + log " api:${DJANGO_PORT} pg:${POSTGRES_PORT} valkey:${VALKEY_PORT} neo4j:${NEO4J_PORT} neo4j-http:${NEO4J_HTTP_PORT}" + all_run + attach_run +} + +setup() { + services_up + deps + migrate + fixtures + ok "Setup complete." + cat < + +One-window dev (tmux inside the terminal): + all api + worker + postgres logs in 3 panes (detached, + blocks until API responds, + ends with parseable api_url= / *_log= / attach_cmd= / stop_cmd= lines) + attach Reattach to the existing dev session + Attaches tmux inside the current terminal pane. + Pane output is also written to _data/logs/{api,worker,postgres}.log + (ANSI-stripped, truncated per run) - usable without attaching + kill Stop tmux + stop containers + remove them (full teardown) + launch Use fixed ports, clear conflicts, then run 'all' and attach tmux + +Platform support: + macOS Supported + Linux Should work when Docker, tmux, and uv are available + Windows Requires script changes before it can be supported + +State (containers postgres + valkey + neo4j): + up Start postgres + valkey + neo4j (waits for healthchecks) + down Stop tmux + postgres + valkey + neo4j (keeps containers around) + status Show container status + clean Remove all stopped containers in the project (data volumes preserved) + wipe Full nuke: kill + clean + delete ./_data/ + +Python (native, foreground - usually launched via 'all'): + api Run Django API (runserver) + worker Run Celery worker + beat Run Celery beat scheduler + +One-shots: + setup up + deps + migrate + fixtures (first-time / fresh DB) + deps uv sync inside api/ + migrate Apply migrations to admin DB + fixtures Load dev fixtures + +Typical flow: + make dev-setup # first time only + make dev # daily dev + make dev-stop # when done +EOF +} + +case "${1:-help}" in + all) shift; if [ "$#" -gt 0 ]; then warn "'all $*' is not supported. Use: $0 all"; exit 1; fi; all_run ;; + attach) attach_run ;; + kill) kill_run ;; + launch) launch_run ;; + clean) clean_run ;; + wipe) wipe_run ;; + up) services_up ;; + down) services_down ;; + status) services_status ;; + api) api_run ;; + worker) worker_run ;; + beat) beat_run ;; + setup) setup ;; + deps) deps ;; + migrate) migrate ;; + fixtures) fixtures ;; + help|-h|--help|*) usage ;; +esac diff --git a/skills/prowler-tour/SKILL.md b/skills/prowler-tour/SKILL.md new file mode 100644 index 0000000000..8511581a47 --- /dev/null +++ b/skills/prowler-tour/SKILL.md @@ -0,0 +1,99 @@ +--- +name: prowler-tour +description: > + Keeps product-tour definitions aligned with the UI features they describe. + Trigger: When modifying UI components that have associated tours, editing tour + definition files, or renaming data-tour-id attributes. +license: Apache-2.0 +metadata: + author: prowler-cloud + version: "1.0" + scope: [root, ui] + auto_invoke: + - "Editing a UI file containing data-tour-id attributes" + - "Adding, updating, or removing a tour definition (*.tour.ts)" + - "Renaming or removing a data-tour-id attribute value" + - "Changing button labels or section headings on a tour-covered page" + - "Restructuring routes or layouts covered by a tour" +allowed-tools: Read, Glob, Grep +--- + +# prowler-tour + +**Report-only.** This skill never edits tour files or UI files; it inspects +the change, reports drift it finds between tours and the covered UI, and +recommends actions for the developer to apply. + +## Early-exit rule + +Run this check first. Most UI edits are not tour-related — exit cheaply. + +1. Glob `ui/lib/tours/*.tour.ts`. +2. For each tour, check whether any `coversFiles` glob pattern matches any + file in the current change. +3. If no tour matches, respond **exactly**: + + > No tour affected — skipping alignment check + + and exit. Do not proceed to the checklist. +4. If at least one tour matches, continue to "Drift checklist" for that tour. + +## Drift checklist + +For each affected tour, evaluate every item. Skip items that obviously do +not apply, but list explicitly which items were checked. + +1. **Orphan selectors** — every step's `target` (which composes to + `data-tour-id="-"`) must resolve to a real element + in the codebase. Grep `ui/` for the expected attribute value; report + any step whose target is missing. +2. **Renamed selectors** — a `data-tour-id` attribute was edited in this + change. Match it back to any tour step referencing the old value. +3. **Outdated copy** — a popover `title`/`description` references a button + label, heading, or term that no longer exists on the covered page. +4. **Obsolete steps** — a step describes a section, panel, or workflow + that was removed. +5. **Missing steps** — a new feature was added on the covered surface + without a corresponding step (e.g. a new panel, a new primary action, + a new wizard stage). +6. **Reordered flow** — the user's path through the feature changed (e.g. + query builder moved before scan selection) and the step order no + longer reflects it. + +## Version-bump decision tree + +Apply per tour after listing drift: + +- **NO bump** when the change is cosmetic. Examples: fix a typo, soften + copy, rename a `data-tour-id` selector while keeping the same step, + swap one screenshot for another, tighten wording. +- **BUMP `version`** when the user-visible flow changes materially. + Examples: a new step was added or removed; the order changed; an + anchored target was retargeted to a different panel; the tour now + covers a new feature on the surface. + +When in doubt, ask: "Would a user who already saw the previous version +miss something useful by not seeing this one?" If yes, bump. + +## Output format + +When emitting a report, follow the exact structure in +`references/output-format.md`. The structure is mandatory because the +report is consumed downstream and tolerates no field reordering. + +## What this skill MUST NOT do + +- Do not edit `*.tour.ts` files. This skill is report-only. +- Do not edit UI files to add or rename `data-tour-id` attributes. +- Do not invent new tours. Authoring a new tour is a separate, deliberate + decision — the developer makes it, not the skill. +- Do not flag drift in tours whose `coversFiles` do not match any file + in the current change. Stick to the early-exit rule. + +## See also + +- `references/output-format.md` — exact report template (read when + emitting a report). +- `references/tours-architecture.md` — code map for the tour abstraction + under `ui/lib/tours/`. +- `assets/tour-template.ts` — boilerplate for authoring a new `*.tour.ts`. diff --git a/skills/prowler-tour/assets/tour-template.ts b/skills/prowler-tour/assets/tour-template.ts new file mode 100644 index 0000000000..cc264a4678 --- /dev/null +++ b/skills/prowler-tour/assets/tour-template.ts @@ -0,0 +1,51 @@ +// @ts-nocheck -- template only; resolves once copied into `ui/lib/tours/` +/** + * Tour template — copy this file to `ui/lib/tours/.tour.ts` and + * fill in the placeholders. See `references/tours-architecture.md` for the + * design context. + * + * Conventions: + * - Declare via `defineTour({...})` (NOT `: TourDefinition`) so TS + * preserves the literal union of `target` values. `useDriverTour` uses + * that union to validate `stepHandlers` keys and `waitForStep` args. + * - `id` is kebab-case and unique across all tours. + * - Anchored steps reference DOM via `data-tour-id="-"`; + * the hook composes the CSS selector automatically. + * - `coversFiles` lists the globs that describe the tour's surface; the + * `prowler-tour` skill consumes this to decide whether to evaluate + * drift on a given change. + * - Material flow changes bump `version`; cosmetic edits do not. + */ +import { + defineTour, + TOUR_STEP_ALIGNMENTS, + TOUR_STEP_SIDES, +} from "@/lib/tours/tour-types"; + +export const yourTour = defineTour({ + id: "your-tour-id", + version: 1, + coversFiles: [ + // List the UI files this tour describes, using globs under `ui/`. + // Example: "ui/app/(prowler)/your-feature/**" + ], + steps: [ + { + // Modal step — no anchor. Use for intros, outros, and any step + // that does not point at a specific DOM element. + title: "Welcome", + description: "Short, plain-English description.", + }, + { + // Anchored step. The hook resolves + // `[data-tour-id="your-tour-id-step-name"]` lazily, so the element + // can be conditionally rendered as long as it exists when the step + // becomes active. + target: "step-name", + side: TOUR_STEP_SIDES.BOTTOM, + align: TOUR_STEP_ALIGNMENTS.START, + title: "Where the action is", + description: "Tell the user what to look at here and why.", + }, + ], +}); diff --git a/skills/prowler-tour/references/output-format.md b/skills/prowler-tour/references/output-format.md new file mode 100644 index 0000000000..ecd82c4d8e --- /dev/null +++ b/skills/prowler-tour/references/output-format.md @@ -0,0 +1,31 @@ +# Tour Alignment Report — output format + +The report is consumed downstream. Field names, order, and headings are +load-bearing — do not rename, reorder, or omit them. + +## Template + +```text +## Tour Alignment Report +**Tour:** `@v` +**Files touched:** + +### Drift detected +- + +### Recommended actions +1. + +### Version bump verdict +- +``` + +## Rules + +- One report per affected tour. If multiple tours are affected, separate + reports with a `---` line. +- If no drift is detected for an affected tour, still emit the report: + put "No drift detected." under "Drift detected" and "None required." + under "Recommended actions". The verdict line is still mandatory. +- The verdict is exactly one of `BUMP` or `NO bump` — see the + version-bump decision tree in `SKILL.md`. diff --git a/skills/prowler-tour/references/tours-architecture.md b/skills/prowler-tour/references/tours-architecture.md new file mode 100644 index 0000000000..9a5c8f49e0 --- /dev/null +++ b/skills/prowler-tour/references/tours-architecture.md @@ -0,0 +1,44 @@ +# Tours Architecture + +The product-tour abstraction lives under [`ui/lib/tours/`](../../../ui/lib/tours/). +This skill operates on tour definitions that follow this architecture. + +## Code map + +| File | Purpose | +|---|---| +| `ui/lib/tours/tour-types.ts` | Public type surface: `TourDefinition`, `TourStep`, `TourId`, `TourCompletionRecord`, completion-state const map. Also exports `defineTour(...)` — the required authoring helper that preserves literal step `target`s so `useDriverTour` can type-check `stepHandlers` keys and `waitForStep` arguments. | +| `ui/lib/tours/tour-config.ts` | `baseDriverConfig`, `getDriverConfig(theme, overrides?)`, overlay-color map. | +| `ui/lib/tours/store/tour-completion-store.ts` | Persistence interface — the swap point for future API adapters. | +| `ui/lib/tours/store/local-storage-adapter.ts` | The only adapter in the PoC. Key format: `prowler.tour..v`. | +| `ui/lib/tours/use-driver-tour.ts` | React hook. Initializes driver.js, derives `overlayColor` from `useTheme()`, persists completion. | +| `ui/lib/tours/.tour.ts` | One file per tour. Declared via `defineTour({...})` (not `: TourDefinition`) and imported by the page that opts the user in. | +| `ui/styles/tours.css` | `.driver-popover.prowler-theme` — every color resolved via `var(--...)` from `globals.css`. | + +## Selector convention + +Tour steps anchor via `data-tour-id="-"`. The hook +composes the CSS selector at runtime; tour authors only provide the step +name in `step.target`. Class-based, ID-based, structural selectors are +forbidden — they couple tours to styling decisions that legitimately +change. + +## Identity and versioning + +A tour is `{ id, version }`. The localStorage key composes both. A +**material content change** bumps `version`; cosmetic edits do not. The +decision tree lives in the parent SKILL.md. + +## Persistence scope + +Per-user, cross-tenant. A user who completed `attack-paths@v1` in tenant +A does not see the tour again in tenant B, even if they can access the +feature there. The future `UserTourState` model (documented in +`design.md`, not built) is FK to `User`, not `Membership`. + +## Drift = #1 risk + +Without the maintenance skill + the optional CI gate +(`ui/scripts/check-tour-alignment.mjs`), tours decay silently as the +covered UI evolves. The parent SKILL.md enumerates the six drift +categories the skill checks for. diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/common_methods_test.py b/tests/dashboard/common_methods_test.py new file mode 100644 index 0000000000..b2137589c5 --- /dev/null +++ b/tests/dashboard/common_methods_test.py @@ -0,0 +1,81 @@ +import pandas as pd +from dash import dash_table + +from dashboard.common_methods import get_section_containers_generic + + +def _datatable_column_ids(component): + """Collect the column ids of every DataTable in a Dash component tree.""" + if isinstance(component, dash_table.DataTable): + return [[c["id"] for c in component.columns]] + children = getattr(component, "children", None) + if children is None: + return [] + if not isinstance(children, (list, tuple)): + children = [children] + return [cols for child in children for cols in _datatable_column_ids(child)] + + +def _df(**extra): + data = { + "REQUIREMENTS_ID": ["req1"], + "STATUS": ["PASS"], + "CHECKID": ["check1"], + "REGION": ["us-east-1"], + "ACCOUNTID": ["123"], + "RESOURCEID": ["res1"], + } + data.update(extra) + return pd.DataFrame(data) + + +class TestGetSectionContainersGeneric: + def test_one_container_per_section(self): + """One outer container per distinct section value.""" + df = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req2", "req3"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["c1", "c2", "c3"], + "REGION": ["-"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["r1", "r2", "r3"], + } + ) + result = get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + assert len(result.children) == 2 + + def test_inner_title_includes_id_and_description(self): + """Inner accordion title is ' - '.""" + df = _df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + REQUIREMENTS_DESCRIPTION=["Ensure MFA"], + ) + rendered = str( + get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + ) + assert "req1 - Ensure MFA" in rendered + + def test_arbitrary_ids_do_not_crash(self): + """Non-numeric ids are sorted lexicographically without raising.""" + df = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A"] * 3, + "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["c1", "c2", "c3"], + "REGION": ["-"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["r1", "r2", "r3"], + } + ) + result = get_section_containers_generic( + df, "REQUIREMENTS_ATTRIBUTES_SECTION", "REQUIREMENTS_ID" + ) + tables = _datatable_column_ids(result) + assert tables and all("CHECKID" in cols for cols in tables) diff --git a/tests/dashboard/compliance/__init__.py b/tests/dashboard/compliance/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/compliance/generic_test.py b/tests/dashboard/compliance/generic_test.py new file mode 100644 index 0000000000..4e36833ada --- /dev/null +++ b/tests/dashboard/compliance/generic_test.py @@ -0,0 +1,204 @@ +import pandas as pd +from dash import dash_table, html + +from dashboard.compliance.generic import get_table + + +def _make_minimal_df(**extra_cols): + """Create a minimal valid DataFrame for get_table tests.""" + data = { + "REQUIREMENTS_ID": ["req1"], + "STATUS": ["PASS"], + "CHECKID": ["check1"], + "REGION": ["us-east-1"], + "ACCOUNTID": ["123456789"], + "RESOURCEID": ["res1"], + } + data.update(extra_cols) + return pd.DataFrame(data) + + +def _datatable_column_ids(component): + """Collect the column ids of every DataTable in a Dash component tree.""" + if isinstance(component, dash_table.DataTable): + return [[c["id"] for c in component.columns]] + children = getattr(component, "children", None) + if children is None: + return [] + if not isinstance(children, (list, tuple)): + children = [children] + return [cols for child in children for cols in _datatable_column_ids(child)] + + +class TestGetTable: + def test_groups_by_section(self): + """SC-001a: df with REQUIREMENTS_ATTRIBUTES_SECTION returns Div grouped by section.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": [ + "Section A", + "Section A", + "Section A", + "Section B", + "Section B", + ], + "REQUIREMENTS_ID": [ + "ctrl-alpha", + "ctrl-alpha", + "ctrl-alpha", + "ctrl-beta", + "ctrl-beta", + ], + "STATUS": ["PASS", "FAIL", "PASS", "FAIL", "FAIL"], + "CHECKID": ["check1", "check2", "check3", "check4", "check5"], + "REGION": ["us-east-1"] * 5, + "ACCOUNTID": ["123"] * 5, + "RESOURCEID": ["res1", "res2", "res3", "res4", "res5"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.className == "compliance-data-layout" + assert len(result.children) == 2 # one container per distinct section + + def test_flat_fallback_no_attributes(self): + """SC-001b: No REQUIREMENTS_ATTRIBUTES_* cols → grouped by REQUIREMENTS_ID.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["req1", "req1", "req2"], + "STATUS": ["PASS", "FAIL", "FAIL"], + "CHECKID": ["check1", "check2", "check3"], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["res1", "res2", "res3"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.className == "compliance-data-layout" + # 2 distinct REQUIREMENTS_ID values → 2 group containers + assert len(result.children) == 2 + + def test_arbitrary_ids_no_crash(self): + """ADR-2 / R1 regression guard: non-numeric REQUIREMENTS_IDs must not raise ValueError. + + get_section_containers_cis sorts by version_tuple which calls int() on each + dotted/dashed segment and crashes on IDs like 'AC-2(1)'. Selecting format4 + (no version sort) is the fix. This test is a permanent guard against regression. + """ + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["AC-2(1)", "foo-bar", "step.1.2"], + "STATUS": ["PASS", "FAIL", "PASS"], + "CHECKID": ["check1", "check2", "check3"], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + "RESOURCEID": ["res1", "res2", "res3"], + } + ) + # Must not raise ValueError + result = get_table(data) + assert isinstance(result, html.Div) + + def test_discovers_multiple_attribute_columns(self): + """SC-005a: Multiple REQUIREMENTS_ATTRIBUTES_* cols present → no AttributeError; + component tree is non-empty.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"], + "REQUIREMENTS_ATTRIBUTES_CATEGORY": ["Cat 1", "Cat 2"], + "REQUIREMENTS_ATTRIBUTES_CONTROL_ID": ["C1", "C2"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert result.children # non-empty component tree + + def test_novel_attribute_column_names(self): + """SC-005b: Novel attr col names without a SECTION col → first attr col used as + grouping; returns a valid html.Div without any code change required.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_DOMAIN": ["Domain A", "Domain B"], + "REQUIREMENTS_ATTRIBUTES_SUBDOMAIN": ["Sub 1", "Sub 2"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert len(result.children) > 0 + + def test_manual_only_requirements(self): + """SC-008a: All rows have STATUS='MANUAL' → returns html.Div with non-empty + children; result is not the 'No data found' string.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req2"], + "STATUS": ["MANUAL", "MANUAL"], + "CHECKID": ["check1", "check2"], + "REGION": ["us-east-1"] * 2, + "ACCOUNTID": ["123"] * 2, + "RESOURCEID": ["res1", "res2"], + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + assert not isinstance(result, str) + assert result.children # non-empty + + def test_empty_dataframe(self): + """SC-009a: Zero rows with correct column schema → valid html.Div; no exception.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": pd.Series([], dtype=str), + "REQUIREMENTS_ID": pd.Series([], dtype=str), + "STATUS": pd.Series([], dtype=str), + "CHECKID": pd.Series([], dtype=str), + "REGION": pd.Series([], dtype=str), + "ACCOUNTID": pd.Series([], dtype=str), + "RESOURCEID": pd.Series([], dtype=str), + } + ) + result = get_table(data) + assert isinstance(result, html.Div) + + def test_get_table_returns_html_div(self): + """SC-012a: Smoke test — isinstance(get_table(df), html.Div) is True.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + ) + result = get_table(data) + assert isinstance(result, html.Div) + + +class TestNestedRendering: + def test_section_and_requirement_id_are_separate_levels(self): + """Section is the outer level; requirement id + description the inner.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["3 Compute Services"], + REQUIREMENTS_DESCRIPTION=["Ensure only MFA enabled identities"], + ) + rendered = str(get_table(data)) + assert "3 Compute Services" in rendered + assert "req1 - Ensure only MFA enabled identities" in rendered + + def test_checks_table_is_nested_under_requirement(self): + """The checks table sits at the innermost level.""" + data = _make_minimal_df( + REQUIREMENTS_ATTRIBUTES_SECTION=["Sec A"], + REQUIREMENTS_DESCRIPTION=["Some requirement"], + ) + tables = _datatable_column_ids(get_table(data)) + assert tables and all("CHECKID" in cols for cols in tables) diff --git a/tests/dashboard/pages/__init__.py b/tests/dashboard/pages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/pages/compliance_dispatch_test.py b/tests/dashboard/pages/compliance_dispatch_test.py new file mode 100644 index 0000000000..78bd88ce68 --- /dev/null +++ b/tests/dashboard/pages/compliance_dispatch_test.py @@ -0,0 +1,179 @@ +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest +from dash import html + +from dashboard.pages.compliance import _dispatch_compliance_renderer + + +def _make_dispatch_df(**extra_cols): + """Minimal DataFrame with the columns required by the dedup step.""" + data = { + "REQUIREMENTS_ID": ["req1", "req2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"], + "STATUS": ["PASS", "FAIL"], + "CHECKID": ["check1", "check2"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["", ""], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + } + data.update(extra_cols) + return pd.DataFrame(data) + + +class TestDispatchComplianceRenderer: + def test_builtin_name_uses_builtin_module(self): + """SC-002a: analytics_input='cis_4_0_aws' resolves real builtin module; + returns (html.Div, DataFrame) 2-tuple.""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["1.1", "1.2"], + "REQUIREMENTS_DESCRIPTION": ["Description 1", "Description 2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"], + "CHECKID": ["check1", "check2"], + "STATUS": ["PASS", "FAIL"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["Pass", "Fail"], + } + ) + table, result_data = _dispatch_compliance_renderer(data, "cis_4_0_aws") + assert isinstance(table, html.Div) + assert isinstance(result_data, pd.DataFrame) + + def test_unknown_name_falls_back_to_generic(self): + """SC-003a: Unknown analytics_input raises ModuleNotFoundError → generic + fallback is called with the deduped dataframe.""" + data = _make_dispatch_df() + sentinel = MagicMock( + return_value=html.Div([], className="compliance-data-layout") + ) + + with patch("dashboard.compliance.generic.get_table", sentinel): + table, result_data = _dispatch_compliance_renderer(data, "myfw_dynprovider") + + sentinel.assert_called_once() + assert isinstance(table, html.Div) + assert isinstance(result_data, pd.DataFrame) + + def test_import_error_is_not_swallowed(self): + """SC-003b: ImportError (NOT ModuleNotFoundError) is re-raised; except clause + is exact — only ModuleNotFoundError routes to generic.""" + data = _make_dispatch_df() + + with patch( + "dashboard.pages.compliance.importlib.import_module", + side_effect=ImportError("custom error"), + ): + with pytest.raises(ImportError, match="custom error"): + _dispatch_compliance_renderer(data, "anything") + + def test_get_table_error_in_generic_surfaces(self): + """SC-004a: ValueError from generic.get_table propagates (not swallowed); + get_table is called OUTSIDE the try block.""" + data = _make_dispatch_df() + + with patch( + "dashboard.compliance.generic.get_table", + side_effect=ValueError("boom"), + ): + with pytest.raises(ValueError, match="boom"): + _dispatch_compliance_renderer(data, "myfw_dynprovider") + + def test_get_table_error_in_builtin_surfaces(self): + """REQ-004 / ADR-1: RuntimeError from a builtin get_table propagates; + proving get_table is called outside the try block.""" + data = _make_dispatch_df() + mock_module = MagicMock() + mock_module.get_table.side_effect = RuntimeError("table error") + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + with pytest.raises(RuntimeError, match="table error"): + _dispatch_compliance_renderer(data, "some_builtin_fw") + + def test_dedup_applied_before_get_table(self): + """ADR-1: Duplicate rows (identical CHECKID/STATUS/RESOURCEID/STATUSEXTENDED) + are dropped; returned data has the deduplicated row count.""" + # Row 0 and row 1 are identical in all dedup-key columns; row 2 is unique. + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A", "Sec B"], + "REQUIREMENTS_ID": ["req1", "req1", "req2"], + "STATUS": ["PASS", "PASS", "FAIL"], + "CHECKID": ["check1", "check1", "check2"], + "RESOURCEID": ["res1", "res1", "res2"], + "STATUSEXTENDED": ["", "", ""], + "REGION": ["us-east-1"] * 3, + "ACCOUNTID": ["123"] * 3, + } + ) + mock_module = MagicMock() + mock_module.get_table.return_value = html.Div([]) + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + table, result_data = _dispatch_compliance_renderer(data, "some_fw") + + assert len(result_data) == 2 # one duplicate removed + + def test_muted_column_added_to_dedup_when_present(self): + """ADR-1 edge case: When MUTED column is present, it is included in the dedup + subset at index 2; rows differing only in MUTED are kept as distinct rows.""" + # Both rows share CHECKID/STATUS/RESOURCEID/STATUSEXTENDED but differ in MUTED. + # With MUTED in dedup_columns, both rows are kept (2 rows after dedup). + # Without MUTED in dedup_columns, they would be collapsed to 1 row. + data = pd.DataFrame( + { + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Sec A", "Sec A"], + "REQUIREMENTS_ID": ["req1", "req1"], + "STATUS": ["PASS", "PASS"], + "CHECKID": ["check1", "check1"], + "RESOURCEID": ["res1", "res1"], + "STATUSEXTENDED": ["", ""], + "MUTED": ["True", "False"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123", "123"], + } + ) + mock_module = MagicMock() + mock_module.get_table.return_value = html.Div([]) + + with patch( + "dashboard.pages.compliance.importlib.import_module", + return_value=mock_module, + ): + table, result_data = _dispatch_compliance_renderer(data, "some_fw") + + # MUTED at idx 2 means these two rows have different dedup keys → both kept + assert len(result_data) == 2 + + def test_returns_table_and_data_tuple(self): + """ADR-1 interface contract: _dispatch_compliance_renderer returns a + 2-tuple (table, deduped_data).""" + data = pd.DataFrame( + { + "REQUIREMENTS_ID": ["1.1", "1.2"], + "REQUIREMENTS_DESCRIPTION": ["Desc 1", "Desc 2"], + "REQUIREMENTS_ATTRIBUTES_SECTION": ["Section A", "Section A"], + "CHECKID": ["check1", "check2"], + "STATUS": ["PASS", "FAIL"], + "REGION": ["us-east-1", "us-east-1"], + "ACCOUNTID": ["123456789", "123456789"], + "RESOURCEID": ["res1", "res2"], + "STATUSEXTENDED": ["", ""], + } + ) + result = _dispatch_compliance_renderer(data, "cis_4_0_aws") + assert isinstance(result, tuple) + assert len(result) == 2 + table, deduped_data = result + assert isinstance(table, html.Div) + assert isinstance(deduped_data, pd.DataFrame) diff --git a/tests/dashboard/pages/conftest.py b/tests/dashboard/pages/conftest.py new file mode 100644 index 0000000000..a5c674c7de --- /dev/null +++ b/tests/dashboard/pages/conftest.py @@ -0,0 +1,7 @@ +import dash + +# Initialize a minimal Dash app so that dashboard page modules can call +# dash.register_page() during import without raising PageError. +# This module-level initialization runs during pytest collection, before +# any test file in this directory is imported. +_test_app = dash.Dash("prowler_test_app", use_pages=True, pages_folder="") diff --git a/tests/dashboard/pages/scope_columns_test.py b/tests/dashboard/pages/scope_columns_test.py new file mode 100644 index 0000000000..a8aea34221 --- /dev/null +++ b/tests/dashboard/pages/scope_columns_test.py @@ -0,0 +1,60 @@ +import pandas as pd + +from dashboard.pages.compliance import _ensure_scope_columns + + +def _df(columns): + """Build a one-row DataFrame preserving the given column order.""" + return pd.DataFrame({col: ["x"] for col in columns}) + + +class TestEnsureScopeColumns: + def test_aws_account_and_region_preserved(self): + """A provider that already emits ACCOUNTID and REGION is left untouched.""" + df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "REGION", "ASSESSMENTDATE"]) + result = _ensure_scope_columns(df) + assert "ACCOUNTID" in result.columns + assert "REGION" in result.columns + assert result["ACCOUNTID"].iloc[0] == "x" + + def test_okta_single_scope_column_becomes_accountid(self): + """Okta's ORGANIZATIONDOMAIN becomes ACCOUNTID; REGION falls back.""" + df = _df(["PROVIDER", "DESCRIPTION", "ORGANIZATIONDOMAIN", "ASSESSMENTDATE"]) + df["ORGANIZATIONDOMAIN"] = ["trial-123.okta.com"] + result = _ensure_scope_columns(df) + assert "ACCOUNTID" in result.columns + assert "ORGANIZATIONDOMAIN" not in result.columns + assert result["ACCOUNTID"].iloc[0] == "trial-123.okta.com" + assert result["REGION"].iloc[0] == "-" + + def test_two_unknown_scope_columns_map_to_account_and_region(self): + """Two scope columns map positionally to ACCOUNTID and REGION.""" + df = _df(["PROVIDER", "DESCRIPTION", "TENANCYID", "LOCATION", "ASSESSMENTDATE"]) + df["TENANCYID"] = ["tenant-1"] + df["LOCATION"] = ["eu-west-1"] + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "tenant-1" + assert result["REGION"].iloc[0] == "eu-west-1" + + def test_no_scope_columns_fall_back_to_dash(self): + """No scope columns → both ACCOUNTID and REGION fall back to '-'.""" + df = _df(["PROVIDER", "DESCRIPTION", "ASSESSMENTDATE"]) + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "-" + assert result["REGION"].iloc[0] == "-" + + def test_missing_anchors_still_fall_back_to_dash(self): + """Without DESCRIPTION/ASSESSMENTDATE anchors, both fall back to '-'.""" + df = _df(["PROVIDER", "FOO", "BAR"]) + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "-" + assert result["REGION"].iloc[0] == "-" + + def test_existing_accountid_does_not_consume_region_scope(self): + """An existing ACCOUNTID is kept; the leftover scope becomes REGION.""" + df = _df(["PROVIDER", "DESCRIPTION", "ACCOUNTID", "LOCATION", "ASSESSMENTDATE"]) + df["ACCOUNTID"] = ["acc-1"] + df["LOCATION"] = ["us-east-2"] + result = _ensure_scope_columns(df) + assert result["ACCOUNTID"].iloc[0] == "acc-1" + assert result["REGION"].iloc[0] == "us-east-2" diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index cd602a3a61..94b6ad2b4d 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,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,linode,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -39,6 +39,7 @@ def mock_get_available_providers(): "cloudflare", "openstack", "stackit", + "linode", ] diff --git a/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py new file mode 100644 index 0000000000..6382dc1d2c --- /dev/null +++ b/tests/lib/outputs/compliance/asd_essential_eight/asd_essential_eight_table_test.py @@ -0,0 +1,132 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.asd_essential_eight.asd_essential_eight import ( + get_asd_essential_eight_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="ASD-Essential-Eight"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestASDEssentialEightTable: + """Test cases verifying multi-section counting and provider-column attribution for the ASD Essential Eight compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ASD-Essential-Eight + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_asd_essential_eight_table( + findings, + bulk_metadata, + "asd_essential_eight_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/c5/__init__.py b/tests/lib/outputs/compliance/c5/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/c5/c5_table_test.py b/tests/lib/outputs/compliance/c5/c5_table_test.py new file mode 100644 index 0000000000..c423f5af0b --- /dev/null +++ b/tests/lib/outputs/compliance/c5/c5_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.c5.c5 import get_c5_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="C5"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestC5Table: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched C5 compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_c5_table( + findings, + bulk_metadata, + "c5_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/ccc/ccc_table_test.py b/tests/lib/outputs/compliance/ccc/ccc_table_test.py new file mode 100644 index 0000000000..f647aff3d0 --- /dev/null +++ b/tests/lib/outputs/compliance/ccc/ccc_table_test.py @@ -0,0 +1,130 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ccc.ccc import get_ccc_table + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="CCC"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestCCCTable: + """Test cases verifying multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CCC compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ccc_table( + findings, + bulk_metadata, + "ccc_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/cis/cis_table_test.py b/tests/lib/outputs/compliance/cis/cis_table_test.py new file mode 100644 index 0000000000..c47e1387a0 --- /dev/null +++ b/tests/lib/outputs/compliance/cis/cis_table_test.py @@ -0,0 +1,162 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.cis.cis import get_cis_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(section, profile="Level 1"): + return SimpleNamespace(Section=section, Profile=profile) + + +def _make_compliance(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance with the given (section, profile) attrs.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +def _make_compliance_multi_req(provider, attributes, version="1.4", framework="CIS"): + """Build a per-check CIS compliance where each attr is its own requirement, + simulating a check that appears in several requirements.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=[attr]) for attr in attributes], + ) + + +class TestCISTable: + """Verify multi-section counting and provider-column attribution for the CIS compliance table.""" + + def test_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several sections must increment the + per-section Muted column for every section, not only the first seen. + + CIS counts FAIL/PASS through Level 1/Level 2 buckets, so only the Muted + per-section count was affected by the undercount bug. + """ + bulk_metadata = { + # check_a is muted and belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM"), _attr("2 Logging")]) + ] + ), + # A real (non-muted) finding so the table is rendered. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("1 IAM")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 in their last cell. + # Before the fix only the first section seen got incremented. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_same_section_level_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same section and profile must count once for that section/level, + not once per requirement (FAIL(1), never FAIL(2)).""" + bulk_metadata = { + # check_a is a single FAIL mapped to two requirements, both in the + # same section "1 IAM" and the same profile "Level 1". + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance_multi_req("aws", [_attr("1 IAM"), _attr("1 IAM")]) + ] + ), + # A second finding in another section so the table renders. + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", [_attr("2 Logging")])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The "1 IAM" row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched CIS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("1 IAM")]), + _make_compliance( + "gcp", [_attr("Other")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_cis_table( + findings, + bulk_metadata, + "cis_1.4_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The trailing unrelated framework's provider must not leak in. + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/display_compliance_table_test.py b/tests/lib/outputs/compliance/display_compliance_table_test.py index a55789a7fd..bd854d6d7d 100644 --- a/tests/lib/outputs/compliance/display_compliance_table_test.py +++ b/tests/lib/outputs/compliance/display_compliance_table_test.py @@ -103,6 +103,15 @@ class TestDispatchStartswith: display_compliance_table(compliance_framework=framework_name, **_COMMON) mock_fn.assert_called_once() + @pytest.mark.parametrize( + "framework_name", + ["okta_idaas_stig_v1r2_okta"], + ) + @patch(f"{MODULE}.get_okta_idaas_stig_table") + def test_okta_idaas_stig_dispatch(self, mock_fn, framework_name): + display_compliance_table(compliance_framework=framework_name, **_COMMON) + mock_fn.assert_called_once() + @pytest.mark.parametrize( "framework_name", [ diff --git a/tests/lib/outputs/compliance/ens/ens_table_test.py b/tests/lib/outputs/compliance/ens/ens_table_test.py new file mode 100644 index 0000000000..2373885dbc --- /dev/null +++ b/tests/lib/outputs/compliance/ens/ens_table_test.py @@ -0,0 +1,235 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.ens.ens import get_ens_table + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _attr(marco, categoria, tipo="requisito", nivel="alto"): + return SimpleNamespace(Marco=marco, Categoria=categoria, Tipo=tipo, Nivel=nivel) + + +def _make_compliance(provider, attributes, framework="ENS"): + """Build a per-check ENS compliance with the given marco/categoria attrs.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[SimpleNamespace(Attributes=attributes)], + ) + + +class TestENSTable: + """Test cases for ENS compliance table rendering. + + Verify multi-marco counting and provider-column attribution for the + compliance table. + """ + + def test_no_cumple_marked_in_every_marco(self, capsys, tmp_path): + """A single failing finding mapped to several marcos must mark every + one of them as NO CUMPLE, not only the first marco seen.""" + bulk_metadata = { + # check_a fails and belongs to two distinct marcos/categorias. + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + # A passing finding so the overview total reaches 2. + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the failing finding maps to must read NO CUMPLE. + # Before the fix only the first marco was marked, the second stayed + # CUMPLE. Anchor the assertion to the actual marco rows (not the + # overview header line which also mentions NO CUMPLE). + op_row = [ + line + for line in plain.splitlines() + if "operacional/control de acceso" in line + ] + org_row = [ + line + for line in plain.splitlines() + if "organizativo/politica de seguridad" in line + ] + assert len(op_row) == 1 and "NO CUMPLE" in op_row[0] + assert len(org_row) == 1 and "NO CUMPLE" in org_row[0] + + def test_recomendacion_does_not_set_no_cumple(self, capsys, tmp_path): + """A FAIL on a 'recomendacion' attribute must not flip a marco to + NO CUMPLE (this path is intentionally excluded from the fix).""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr( + "operacional", "control de acceso", tipo="recomendacion" + ) + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("organizativo", "politica")]) + ] + ), + # A regular (non-recomendacion) check so the results table renders + # at least one marco row and the assertion below is not vacuous. + "check_c": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "continuidad")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + _make_finding("check_c", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The recomendacion FAIL must not appear as a NO CUMPLE marco row in the + # results table (the overview header line is allowed to mention it). + marco_rows = [ + line + for line in plain.splitlines() + if "operacional" in line or "organizativo" in line + ] + # Guard against a vacuous pass: the table must actually render rows. + assert marco_rows + assert all("NO CUMPLE" not in line for line in marco_rows) + + def test_muted_multi_marco_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several marcos must increment the + per-marco Muted column for every marco, not only the first seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", + [ + _attr("operacional", "control de acceso"), + _attr("organizativo", "politica de seguridad"), + ], + ) + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", [_attr("operacional", "control de acceso")]) + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both marco rows the muted finding maps to must report a Muted count of + # 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Proveedor column must come from the matched ENS compliance, not + from a different framework that trails it in the compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance( + "aws", [_attr("operacional", "control de acceso")] + ), + _make_compliance( + "gcp", [_attr("x", "y")], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_ens_table( + findings, + bulk_metadata, + "ens_rd2022_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + assert "gcp" not in captured.out diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index f085460abd..a8fc7aa7e5 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -16,6 +16,7 @@ from prowler.lib.check.compliance_models import ( Mitre_Requirement_Attribute_Azure, Mitre_Requirement_Attribute_GCP, Prowler_ThreatScore_Requirement_Attribute, + STIG_Requirement_Attribute, ) CIS_1_4_AWS = Compliance( @@ -1258,3 +1259,47 @@ ASD_ESSENTIAL_EIGHT_AWS = Compliance( ), ], ) + +OKTA_IDAAS_STIG_OKTA = Compliance( + Framework="Okta-IDaaS-STIG", + Name="DISA Okta Identity as a Service (IDaaS) STIG V1R2", + Version="1R2", + Provider="Okta", + Description="Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).", + Requirements=[ + Compliance_Requirement( + Id="OKTA-APP-000020", + Name="Okta must log out a session after a 15-minute period of inactivity.", + Description="A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.", + Attributes=[ + STIG_Requirement_Attribute( + Section="CAT II (Medium)", + Severity="medium", + RuleID="SV-273186r1098825_rule", + StigID="OKTA-APP-000020", + CCI=["CCI-000057", "CCI-001133"], + CheckText="Verify the Global Session Policy logs out a session after 15 minutes of inactivity.", + FixText="From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.", + ) + ], + Checks=["signon_global_session_idle_timeout_15min"], + ), + Compliance_Requirement( + Id="OKTA-APP-000650", + Name="Okta must enforce a minimum 15-character password length.", + Description="The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.", + Attributes=[ + STIG_Requirement_Attribute( + Section="CAT II (Medium)", + Severity="medium", + RuleID="SV-273209r1098894_rule", + StigID="OKTA-APP-000650", + CCI=["CCI-000205"], + CheckText="Verify the password policy enforces a minimum length of 15 characters.", + FixText="From the Admin Console set the minimum password length to 15 characters.", + ) + ], + Checks=[], + ), + ], +) diff --git a/tests/lib/outputs/compliance/kisa_ismsp/__init__.py b/tests/lib/outputs/compliance/kisa_ismsp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py new file mode 100644 index 0000000000..be0fe25ad5 --- /dev/null +++ b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_table_test.py @@ -0,0 +1,137 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_table + +# The generator matches a compliance when its Framework starts with "KISA" and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "kisa-isms-p-2023_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, sections, framework="KISA-ISMS-P", version="kisa-isms-p-2023" +): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestKISAISMSPTable: + """Verify multi-section counting and provider-column attribution for the KISA ISMS-P compliance table.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging was + # undercounted because the per-section count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched KISA compliance, never + from a different framework that happens to be the last entry in the + check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_kisa_ismsp_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/mitre_attack/__init__.py b/tests/lib/outputs/compliance/mitre_attack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py new file mode 100644 index 0000000000..3bd69b44e8 --- /dev/null +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_table_test.py @@ -0,0 +1,140 @@ +import re +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import ( + get_mitre_attack_table, +) + +# The generator matches a compliance when "MITRE-ATTACK" is in its Framework and +# its Version is contained in the compliance_framework argument. +COMPLIANCE_FRAMEWORK = "mitre_attack_aws" + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance( + provider, tactics, framework="MITRE-ATTACK", version="mitre_attack" +): + """Build a per-check compliance covering the given tactics.""" + return SimpleNamespace( + Framework=framework, + Version=version, + Provider=provider, + Requirements=[SimpleNamespace(Tactics=tactics)], + ) + + +class TestMitreAttackTable: + """Test multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_tactic_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several tactics must show FAIL(1) in + every tactic, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both Persistence and Execution must report FAIL(1); before the fix + # Execution was undercounted because the per-tactic count was gated by + # the global dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_tactic_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several tactics must increase the + per-tactic Muted count in every tactic, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence", "Execution"])] + ), + "check_b": SimpleNamespace( + Compliance=[_make_compliance("aws", ["Persistence"])] + ), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A second finding is needed so the table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both Persistence and Execution, so the + # Muted column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched MITRE-ATTACK + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["Persistence"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_mitre_attack_table( + findings, + bulk_metadata, + COMPLIANCE_FRAMEWORK, + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py b/tests/lib/outputs/compliance/okta_idaas_stig/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py new file mode 100644 index 0000000000..fa616c906e --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_okta_test.py @@ -0,0 +1,139 @@ +from datetime import datetime +from io import StringIO +from unittest import mock + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import ( + OktaIDaaSSTIG, +) +from tests.lib.outputs.compliance.fixtures import OKTA_IDAAS_STIG_OKTA +from tests.lib.outputs.fixtures.fixtures import generate_finding_output + +OKTA_ORG_DOMAIN = "dev-12345.okta.com" + + +class TestOktaIDaaSSTIG: + def test_output_transform(self): + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + + output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA) + output_data = output.data[0] + assert isinstance(output_data, OktaIDaaSSTIGModel) + assert output_data.Provider == "okta" + assert output_data.Framework == OKTA_IDAAS_STIG_OKTA.Framework + assert output_data.Name == OKTA_IDAAS_STIG_OKTA.Name + assert output_data.OrganizationDomain == OKTA_ORG_DOMAIN + assert output_data.Description == OKTA_IDAAS_STIG_OKTA.Description + assert output_data.Requirements_Id == OKTA_IDAAS_STIG_OKTA.Requirements[0].Id + assert ( + output_data.Requirements_Name == OKTA_IDAAS_STIG_OKTA.Requirements[0].Name + ) + assert ( + output_data.Requirements_Description + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Description + ) + assert ( + output_data.Requirements_Attributes_Section + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Section + ) + assert ( + output_data.Requirements_Attributes_Severity + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].Severity.value + ) + assert ( + output_data.Requirements_Attributes_RuleID + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].RuleID + ) + assert ( + output_data.Requirements_Attributes_StigID + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].StigID + ) + assert ( + output_data.Requirements_Attributes_CCI + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CCI + ) + assert ( + output_data.Requirements_Attributes_CheckText + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].CheckText + ) + assert ( + output_data.Requirements_Attributes_FixText + == OKTA_IDAAS_STIG_OKTA.Requirements[0].Attributes[0].FixText + ) + assert output_data.Status == "PASS" + assert output_data.StatusExtended == "" + assert output_data.ResourceId == "okta-global-session-policy" + assert output_data.ResourceName == "Default Policy" + assert output_data.CheckId == "signon_global_session_idle_timeout_15min" + assert output_data.Muted is False + # Test manual check + output_data_manual = output.data[1] + assert output_data_manual.Provider == "okta" + assert output_data_manual.Framework == OKTA_IDAAS_STIG_OKTA.Framework + assert output_data_manual.Name == OKTA_IDAAS_STIG_OKTA.Name + assert output_data_manual.OrganizationDomain == "" + assert ( + output_data_manual.Requirements_Id + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Id + ) + assert ( + output_data_manual.Requirements_Attributes_Severity + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].Severity.value + ) + assert ( + output_data_manual.Requirements_Attributes_StigID + == OKTA_IDAAS_STIG_OKTA.Requirements[1].Attributes[0].StigID + ) + assert output_data_manual.Status == "MANUAL" + assert output_data_manual.StatusExtended == "Manual check" + assert output_data_manual.ResourceId == "manual_check" + assert output_data_manual.ResourceName == "Manual check" + assert output_data_manual.CheckId == "manual" + assert output_data_manual.Muted is False + + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta.timestamp", + "2025-01-01 00:00:00", + ) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [ + generate_finding_output( + provider="okta", + account_uid=OKTA_ORG_DOMAIN, + account_name=OKTA_ORG_DOMAIN, + region="global", + service_name="signon", + check_id="signon_global_session_idle_timeout_15min", + resource_uid="okta-global-session-policy", + resource_name="Default Policy", + compliance={"Okta-IDaaS-STIG-1R2": ["OKTA-APP-000020"]}, + ) + ] + output = OktaIDaaSSTIG(findings, OKTA_IDAAS_STIG_OKTA) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + expected_csv = f"PROVIDER;DESCRIPTION;ORGANIZATIONDOMAIN;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SEVERITY;REQUIREMENTS_ATTRIBUTES_RULEID;REQUIREMENTS_ATTRIBUTES_STIGID;REQUIREMENTS_ATTRIBUTES_CCI;REQUIREMENTS_ATTRIBUTES_CHECKTEXT;REQUIREMENTS_ATTRIBUTES_FIXTEXT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;{OKTA_ORG_DOMAIN};{datetime.now()};OKTA-APP-000020;Okta must log out a session after a 15-minute period of inactivity.;A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate vicinity of the information system.;CAT II (Medium);medium;SV-273186r1098825_rule;OKTA-APP-000020;['CCI-000057', 'CCI-001133'];Verify the Global Session Policy logs out a session after 15 minutes of inactivity.;From the Admin Console configure the Global Session Policy idle timeout to 15 minutes.;PASS;;okta-global-session-policy;Default Policy;signon_global_session_idle_timeout_15min;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\nokta;Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS).;;{datetime.now()};OKTA-APP-000650;Okta must enforce a minimum 15-character password length.;The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised.;CAT II (Medium);medium;SV-273209r1098894_rule;OKTA-APP-000650;['CCI-000205'];Verify the password policy enforces a minimum length of 15 characters.;From the Admin Console set the minimum password length to 15 characters.;MANUAL;Manual check;manual_check;Manual check;manual;False;Okta-IDaaS-STIG;DISA Okta Identity as a Service (IDaaS) STIG V1R2\r\n" + + assert content == expected_csv diff --git a/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py new file mode 100644 index 0000000000..3017ea4f92 --- /dev/null +++ b/tests/lib/outputs/compliance/okta_idaas_stig/okta_idaas_stig_table_test.py @@ -0,0 +1,136 @@ +from types import SimpleNamespace + +from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import ( + get_okta_idaas_stig_table, +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, sections, framework="Okta-IDaaS-STIG"): + """Build a per-check compliance covering the given sections.""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace(Attributes=[SimpleNamespace(Section=section)]) + for section in sections + ], + ) + + +class TestOktaIDaaSSTIGTable: + """Test cases for Okta IDaaS STIG compliance table rendering.""" + + def test_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several sections must show FAIL(1) in + every section, not just the first one seen.""" + bulk_metadata = { + # check_a belongs to two sections at once. + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Logging must report FAIL(1); before the fix Logging + # was undercounted and rendered as plain PASS. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several sections must increase the + per-section Muted count in every section, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("okta", ["IAM", "Logging"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("okta", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # The muted check belongs to both IAM and Logging, so the Muted column + # must read 1 in both rows. Before the fix only the first section seen + # was incremented, leaving the second at 0. + # Strip ANSI color codes before counting the bare values per row. + import re + + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # Each section row ends with its Muted value in its own cell; both rows + # must carry a Muted count of 1. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched Okta-IDaaS-STIG + compliance, never from a different framework that happens to be the + last entry in the check's compliance list.""" + # check_a maps to Okta-IDaaS-STIG (provider "okta") but its compliance + # list ends with a *different* framework whose provider is "aws". With + # the bug the leaked loop variable made the table render "aws". + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("okta", ["IAM"]), + _make_compliance("aws", ["Other"], framework="OtherFramework"), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + get_okta_idaas_stig_table( + findings, + bulk_metadata, + "okta_idaas_stig_1r2", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "okta" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "aws" not in captured.out diff --git a/tests/lib/outputs/compliance/prowler_threatscore/__init__.py b/tests/lib/outputs/compliance/prowler_threatscore/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py new file mode 100644 index 0000000000..d754395dc3 --- /dev/null +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_table_test.py @@ -0,0 +1,147 @@ +import re +from types import SimpleNamespace +from unittest import mock + +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import ( + get_prowler_threatscore_table, +) + +# Patch target for the Compliance.get_bulk lookup used to render pillars without +# findings; the tests don't exercise that path so it returns nothing. +COMPLIANCE_PATH = ( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore.Compliance" +) + + +def _make_finding(check_id, status="PASS", muted=False): + return SimpleNamespace( + check_metadata=SimpleNamespace(CheckID=check_id), + status=status, + muted=muted, + ) + + +def _make_compliance(provider, pillars, framework="ProwlerThreatScore"): + """Build a per-check compliance covering the given pillars (Section).""" + return SimpleNamespace( + Framework=framework, + Provider=provider, + Requirements=[ + SimpleNamespace( + Attributes=[SimpleNamespace(Section=pillar, LevelOfRisk=5, Weight=100)] + ) + for pillar in pillars + ], + ) + + +class TestProwlerThreatScoreTable: + """Verify multi-section counting and provider-column attribution for the compliance table.""" + + def test_multi_pillar_fail_not_undercounted(self, capsys, tmp_path): + """A single FAIL check mapped to several pillars must show FAIL(1) in + every pillar, not just the first one seen.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + # Both IAM and Encryption must report FAIL(1); before the fix Encryption + # was undercounted because the per-pillar count was gated by the global + # dedup list. + assert captured.out.count("FAIL(1)") == 2 + + def test_multi_pillar_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED check mapped to several pillars must increase the + per-pillar Muted count in every pillar, not only the first one.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[_make_compliance("aws", ["IAM", "Encryption"])] + ), + "check_b": SimpleNamespace(Compliance=[_make_compliance("aws", ["IAM"])]), + } + findings = [ + _make_finding("check_a", "FAIL", muted=True), + # A real FAIL is needed so the results table is rendered at all. + _make_finding("check_b", "FAIL"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + plain = re.sub(r"\x1b\[[0-9;]*m", "", captured.out) + # The muted check belongs to both IAM and Encryption, so the Muted + # column must read 1 in both rows. + muted_cells = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_cells) == 2 + + def test_provider_column_not_leaked_from_other_framework(self, capsys, tmp_path): + """The Provider column must come from the matched ProwlerThreatScore + compliance, never from a different framework that happens to be the last + entry in the check's compliance list.""" + bulk_metadata = { + "check_a": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + "check_b": SimpleNamespace( + Compliance=[ + _make_compliance("aws", ["IAM"]), + _make_compliance( + "leaked_provider", ["Other"], framework="OtherFramework" + ), + ] + ), + } + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + + with mock.patch(COMPLIANCE_PATH) as compliance_mock: + compliance_mock.get_bulk.return_value = {} + get_prowler_threatscore_table( + findings, + bulk_metadata, + "prowler_threatscore_aws", + "output", + str(tmp_path), + False, + ) + + captured = capsys.readouterr() + assert "aws" in captured.out + # The provider of the unrelated trailing framework must NOT leak into + # the rendered table. + assert "leaked_provider" not in captured.out diff --git a/tests/lib/outputs/compliance/universal/universal_table_test.py b/tests/lib/outputs/compliance/universal/universal_table_test.py index 7598c43c8b..abe3d0c3d0 100644 --- a/tests/lib/outputs/compliance/universal/universal_table_test.py +++ b/tests/lib/outputs/compliance/universal/universal_table_test.py @@ -1,3 +1,4 @@ +import re from types import SimpleNamespace from unittest.mock import MagicMock @@ -26,6 +27,10 @@ def _make_finding(check_id, status="PASS", muted=False): return finding +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*m", "", text) + + def _make_framework(requirements, table_config, provider="AWS"): return ComplianceFramework( framework="TestFW", @@ -39,6 +44,8 @@ def _make_framework(requirements, table_config, provider="AWS"): class TestBuildRequirementCheckMap: + """Test cases for building the requirement-to-check map of a framework.""" + def test_basic(self): reqs = [ UniversalComplianceRequirement( @@ -103,6 +110,8 @@ class TestBuildRequirementCheckMap: class TestGetGroupKey: + """Test cases for resolving the group key of a requirement.""" + def test_normal_field(self): req = UniversalComplianceRequirement( id="1.1", @@ -124,7 +133,9 @@ class TestGetGroupKey: class TestGroupedMode: - def test_grouped_rendering(self, capsys): + """Test cases for grouped-mode universal compliance table rendering.""" + + def test_grouped_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -156,7 +167,7 @@ class TestGroupedMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -167,9 +178,118 @@ class TestGroupedMode: assert "PASS" in captured.out assert "FAIL" in captured.out + def test_grouped_multi_section_no_undercount(self, capsys, tmp_path): + """A single check mapped to several sections must be counted in + every section it belongs to, not only the first one seen.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging sections; check_b + # (PASS, IAM only) is added so the overview total reaches 2 and the + # results table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both the IAM and Logging rows must report FAIL(1). Before the fix the + # second section seen (Logging) was undercounted to FAIL(0) and rendered + # as PASS. Anchor each occurrence to its own table row so an unrelated + # "FAIL(1)" elsewhere cannot mask an undercount. + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + + def test_grouped_multi_section_muted_not_undercounted(self, capsys, tmp_path): + """A single MUTED finding mapped to several groups must be counted in + the per-group Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig(group_by="Section") + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both IAM and Logging; check_b is a + # plain FAIL so the overview total reaches 2 and the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The muted finding belongs to both sections, so both the IAM row and + # the Logging row must carry a Muted count of 1 in their last cell. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + class TestSplitMode: - def test_split_rendering(self, capsys): + """Test cases for split-mode universal compliance table rendering.""" + + def test_split_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -204,7 +324,7 @@ class TestSplitMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -214,9 +334,119 @@ class TestSplitMode: assert "Level 1" in captured.out assert "Level 2" in captured.out + def test_split_muted_multi_section_not_undercounted(self, capsys, tmp_path): + """In split mode a single MUTED finding mapped to several groups must + be counted in the Muted column of every group it belongs to.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + # check_a is MUTED and belongs to both Storage and Logging; check_b is a + # plain FAIL so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL", muted=True), + _make_finding("check_b", "FAIL"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # Both section rows must carry a Muted count of 1 (last cell). Before the + # fix only the first group seen incremented Muted, leaving the other 0. + muted_one_rows = re.findall(r"│\s*1\s*│\s*$", plain, flags=re.MULTILINE) + assert len(muted_one_rows) == 2 + + def test_split_same_group_value_not_double_counted(self, capsys, tmp_path): + """A single finding whose check maps to several requirements that share + the same group and split value must count once for that group/split, + not once per requirement (FAIL(1), never FAIL(2)).""" + reqs = [ + # check_a appears in two requirements, both Storage / Level 1. + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + UniversalComplianceRequirement( + id="1.2", + description="test2", + attributes={"Section": "Storage", "Profile": "Level 1"}, + checks={"aws": ["check_a"]}, + ), + # A second group so the table renders with more than one finding. + UniversalComplianceRequirement( + id="2.1", + description="test3", + attributes={"Section": "Logging", "Profile": "Level 1"}, + checks={"aws": ["check_b"]}, + ), + ] + tc = TableConfig( + group_by="Section", + split_by=SplitByConfig(field="Profile", values=["Level 1", "Level 2"]), + ) + fw = _make_framework(reqs, tc) + + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + # The Storage row must show FAIL(1) for Level 1, never FAIL(2). + assert "FAIL(1)" in plain + assert "FAIL(2)" not in plain + class TestScoredMode: - def test_scored_rendering(self, capsys): + """Test cases for scored-mode universal compliance table rendering.""" + + def test_scored_rendering(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -251,7 +481,7 @@ class TestScoredMode: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -261,9 +491,68 @@ class TestScoredMode: assert "Score" in captured.out assert "Threat Score" in captured.out + def test_scored_multi_section_fail_not_undercounted(self, capsys, tmp_path): + """In scored mode a single FAIL finding mapped to several groups must + show FAIL(1) in every group it belongs to, not only the first one.""" + reqs = [ + UniversalComplianceRequirement( + id="1.1", + description="test", + attributes={"Section": "IAM", "LevelOfRisk": 5, "Weight": 100}, + checks={"aws": ["check_a", "check_b"]}, + ), + UniversalComplianceRequirement( + id="2.1", + description="test2", + attributes={"Section": "Logging", "LevelOfRisk": 3, "Weight": 50}, + checks={"aws": ["check_a"]}, + ), + ] + tc = TableConfig( + group_by="Section", + scoring=ScoringConfig(risk_field="LevelOfRisk", weight_field="Weight"), + ) + fw = _make_framework(reqs, tc) + + # check_a (FAIL) belongs to both IAM and Logging; check_b (PASS, IAM + # only) raises the overview total to 2 so the table is rendered. + findings = [ + _make_finding("check_a", "FAIL"), + _make_finding("check_b", "PASS"), + ] + bulk_metadata = { + "check_a": MagicMock(Compliance=[]), + "check_b": MagicMock(Compliance=[]), + } + + get_universal_table( + findings, + bulk_metadata, + "test_fw", + "output", + str(tmp_path), + False, + framework=fw, + ) + + captured = capsys.readouterr() + plain = _strip_ansi(captured.out) + iam_row = [ + line for line in plain.splitlines() if "IAM" in line and "FAIL(1)" in line + ] + logging_row = [ + line + for line in plain.splitlines() + if "Logging" in line and "FAIL(1)" in line + ] + assert len(iam_row) == 1 + assert len(logging_row) == 1 + class TestCustomLabels: - def test_ens_spanish_labels(self, capsys): + """Test cases for custom-label universal compliance table rendering.""" + + def test_ens_spanish_labels(self, capsys, tmp_path): reqs = [ UniversalComplianceRequirement( id="1.1", @@ -300,7 +589,7 @@ class TestCustomLabels: bulk_metadata, "test_fw", "output", - "/tmp", + str(tmp_path), False, framework=fw, ) @@ -311,7 +600,9 @@ class TestCustomLabels: class TestMultiProviderDictChecks: - def test_only_aws_checks_matched(self, capsys): + """Test cases for multi-provider dict checks in the universal table.""" + + def test_only_aws_checks_matched(self, capsys, tmp_path): """With dict checks and provider='aws', only AWS checks match findings.""" reqs = [ UniversalComplianceRequirement( @@ -352,7 +643,7 @@ class TestMultiProviderDictChecks: bulk_metadata, "multi_cloud", "output", - "/tmp", + str(tmp_path), False, framework=fw, provider="aws", @@ -366,7 +657,9 @@ class TestMultiProviderDictChecks: class TestNoTableConfig: - def test_returns_early_without_table_config(self, capsys): + """Test cases for the universal table when no table config is present.""" + + def test_returns_early_without_table_config(self, capsys, tmp_path): fw = ComplianceFramework( framework="TestFW", name="Test", @@ -374,11 +667,11 @@ class TestNoTableConfig: description="Test", requirements=[], ) - get_universal_table([], {}, "test", "out", "/tmp", False, framework=fw) + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=fw) captured = capsys.readouterr() assert captured.out == "" - def test_returns_early_without_framework(self, capsys): - get_universal_table([], {}, "test", "out", "/tmp", False, framework=None) + def test_returns_early_without_framework(self, capsys, tmp_path): + get_universal_table([], {}, "test", "out", str(tmp_path), False, framework=None) captured = capsys.readouterr() assert captured.out == "" diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index 0f1cb14e25..f843cb8f00 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -761,6 +761,87 @@ class TestFinding: assert finding_output.metadata.Severity == Severity.high assert finding_output.metadata.ResourceType == "mock_resource_type" + def _build_linode_check_output(self): + check_output = MagicMock() + check_output.resource_id = "12345" + check_output.resource_name = "test-instance" + check_output.resource_details = "" + check_output.resource_tags = {} + check_output.region = "us-east" + check_output.status = Status.PASS + check_output.status_extended = "Instance is compliant" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="linode") + check_output.resource = {} + check_output.compliance = {} + return check_output + + def test_generate_output_linode(self): + """Test Linode output generation when the account ID is available.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username="admin", + email="admin@example.com", + account_id="E1AF1B6C-1111-2222-3333-444455556666", + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert finding_output.provider == "linode" + assert finding_output.auth_method == "api_token" + assert finding_output.account_uid == "E1AF1B6C-1111-2222-3333-444455556666" + assert finding_output.account_name == "admin" + + def test_generate_output_linode_without_account_id_falls_back_to_username(self): + """account_uid is required; when account_id is None it must fall back to + the username so findings are never silently dropped.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username="admin", + email="admin@example.com", + account_id=None, + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + # Must not raise a ValidationError and must use the username fallback + assert finding_output.account_uid == "admin" + + def test_generate_output_linode_without_account_id_or_username(self): + """When neither account_id nor username/email is available, account_uid + falls back to the literal provider name.""" + from prowler.providers.linode.models import LinodeIdentityInfo + + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username=None, + email=None, + account_id=None, + ) + + check_output = self._build_linode_check_output() + output_options = MagicMock() + output_options.unix_timestamp = False + + finding_output = Finding.generate_output(provider, check_output, output_options) + + assert finding_output.account_uid == "linode" + def test_generate_output_iac_remote(self): # Mock provider provider = MagicMock() diff --git a/tests/lib/outputs/jira/jira_test.py b/tests/lib/outputs/jira/jira_test.py index 788d6ba7c9..76352cb297 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -339,6 +339,88 @@ class TestJiraIntegration: with pytest.raises(JiraRefreshTokenError): self.jira_integration.refresh_access_token() + @patch("prowler.lib.outputs.jira.jira.requests.post") + @patch.object(Jira, "get_cloud_id", return_value="test_cloud_id") + def test_get_auth_sends_timeout(self, mock_get_cloud_id, mock_post): + """get_auth must pass a request timeout to avoid hanging on an unresponsive Jira.""" + # To disable vulture + mock_get_cloud_id = mock_get_cloud_id + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "test_access_token", + "refresh_token": "test_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + self.jira_integration.get_auth("test_auth_code") + + assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_sends_timeout(self, mock_get): + """get_cloud_id (OAuth path) must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"id": "test_cloud_id"}] + mock_get.return_value = mock_response + + self.jira_integration.get_cloud_id("test_access_token") + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_cloud_id_basic_auth_sends_timeout(self, mock_get): + """get_cloud_id (basic-auth tenant_info path) must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"cloudId": "test_cloud_id"} + mock_get.return_value = mock_response + + self.jira_integration_basic_auth.get_cloud_id(domain=self.domain) + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch("prowler.lib.outputs.jira.jira.requests.post") + def test_refresh_access_token_sends_timeout(self, mock_post): + """refresh_access_token must pass a request timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "new_access_token", + "refresh_token": "new_refresh_token", + "expires_in": 3600, + } + mock_post.return_value = mock_response + + self.jira_integration.refresh_access_token() + + assert mock_post.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + + @patch.object(Jira, "get_access_token", return_value="valid_access_token") + @patch.object( + Jira, "cloud_id", new_callable=PropertyMock, return_value="test_cloud_id" + ) + @patch("prowler.lib.outputs.jira.jira.requests.get") + def test_get_projects_sends_timeout( + self, mock_get, mock_cloud_id, mock_get_access_token + ): + """get_projects must pass a request timeout.""" + # To disable vulture + mock_cloud_id = mock_cloud_id + mock_get_access_token = mock_get_access_token + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = [{"key": "PROJ1", "name": "Project One"}] + mock_get.return_value = mock_response + + self.jira_integration.get_projects() + + assert mock_get.call_args.kwargs["timeout"] == Jira.REQUEST_TIMEOUT + @patch.object(Jira, "get_auth", return_value=None) @patch.object( Jira, diff --git a/tests/lib/outputs/outputs_test.py b/tests/lib/outputs/outputs_test.py index 87901bd290..ca18717847 100644 --- a/tests/lib/outputs/outputs_test.py +++ b/tests/lib/outputs/outputs_test.py @@ -51,9 +51,7 @@ class TestOutputs: def test_parse_html_string(self): string = "CISA: your-systems-3, your-data-1, your-data-2 | CIS-1.4: 2.1.1 | CIS-1.5: 2.1.1 | GDPR: article_32 | AWS-Foundational-Security-Best-Practices: s3 | HIPAA: 164_308_a_1_ii_b, 164_308_a_4_ii_a, 164_312_a_2_iv, 164_312_c_1, 164_312_c_2, 164_312_e_2_ii | GxP-21-CFR-Part-11: 11.10-c, 11.30 | GxP-EU-Annex-11: 7.1-data-storage-damage-protection | NIST-800-171-Revision-2: 3_3_8, 3_5_10, 3_13_11, 3_13_16 | NIST-800-53-Revision-4: sc_28 | NIST-800-53-Revision-5: au_9_3, cm_6_a, cm_9_b, cp_9_d, cp_9_8, pm_11_b, sc_8_3, sc_8_4, sc_13_a, sc_16_1, sc_28_1, si_19_4 | ENS-RD2022: mp.si.2.aws.s3.1 | NIST-CSF-1.1: ds_1 | RBI-Cyber-Security-Framework: annex_i_1_3 | FFIEC: d3-pc-am-b-12 | PCI-3.2.1: s3 | FedRamp-Moderate-Revision-4: sc-13, sc-28 | FedRAMP-Low-Revision-4: sc-13 | KISA-ISMS-P-2023: 2.6.1 | KISA-ISMS-P-2023-korean: 2.6.1" - assert ( - parse_html_string(string) - == """ + assert parse_html_string(string) == """ •CISA: your-systems-3, your-data-1, your-data-2 •CIS-1.4: 2.1.1 @@ -94,7 +92,6 @@ class TestOutputs: •KISA-ISMS-P-2023-korean: 2.6.1 """ - ) def test_unroll_tags(self): dict_list = [ diff --git a/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py b/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py new file mode 100644 index 0000000000..bce53165d6 --- /dev/null +++ b/tests/providers/aws/lib/ip_ranges/ip_ranges_test.py @@ -0,0 +1,112 @@ +import json +import urllib.error +from ipaddress import IPv4Network, IPv6Network, ip_address +from unittest.mock import MagicMock, patch + +from prowler.providers.aws.lib.ip_ranges.ip_ranges import get_public_ip_networks + +URLOPEN_TARGET = "prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen" + +SAMPLE_RANGES = { + "prefixes": [ + {"ip_prefix": "54.152.0.0/16", "service": "AMAZON"}, + {"ip_prefix": "3.5.140.0/22", "service": "S3"}, + ], + "ipv6_prefixes": [ + {"ipv6_prefix": "2600:1f00::/24", "service": "AMAZON"}, + ], +} + + +def mock_urlopen(payload): + response = MagicMock() + response.read.return_value = json.dumps(payload).encode() + context_manager = MagicMock() + context_manager.__enter__.return_value = response + context_manager.__exit__.return_value = False + return context_manager + + +class TestGetPublicIPNetworks: + def test_parses_ipv4_and_ipv6_prefixes(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert networks == [ + IPv4Network("54.152.0.0/16"), + IPv4Network("3.5.140.0/22"), + IPv6Network("2600:1f00::/24"), + ] + + def test_known_aws_ip_is_contained(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert any(ip_address("54.152.12.70") in network for network in networks) + + def test_external_ip_is_not_contained(self): + with patch(URLOPEN_TARGET, return_value=mock_urlopen(SAMPLE_RANGES)): + networks = get_public_ip_networks() + + assert not any(ip_address("17.5.7.3") in network for network in networks) + + def test_empty_payload_returns_empty_list(self): + with patch( + "prowler.providers.aws.lib.ip_ranges.ip_ranges.urllib.request.urlopen", + return_value=mock_urlopen({}), + ): + networks = get_public_ip_networks() + + assert networks == [] + + def test_prefixes_missing_cidr_are_skipped(self): + payload = { + "prefixes": [{"ip_prefix": "10.0.0.0/8"}, {"service": "EC2"}], + "ipv6_prefixes": [{"service": "AMAZON"}], + } + with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)): + networks = get_public_ip_networks() + + assert networks == [IPv4Network("10.0.0.0/8")] + + def test_urlopen_failure_returns_empty_list(self): + with patch(URLOPEN_TARGET, side_effect=urllib.error.URLError("boom")): + networks = get_public_ip_networks() + + assert networks == [] + + def test_timeout_returns_empty_list(self): + with patch(URLOPEN_TARGET, side_effect=TimeoutError("timed out")): + networks = get_public_ip_networks() + + assert networks == [] + + def test_invalid_json_returns_empty_list(self): + response = MagicMock() + response.read.return_value = b"not json" + context_manager = MagicMock() + context_manager.__enter__.return_value = response + context_manager.__exit__.return_value = False + with patch(URLOPEN_TARGET, return_value=context_manager): + networks = get_public_ip_networks() + + assert networks == [] + + def test_malformed_cidr_is_skipped(self): + payload = { + "prefixes": [ + {"ip_prefix": "300.0.0.0/8"}, + {"ip_prefix": "10.0.0.0/8"}, + ], + "ipv6_prefixes": [ + {"ipv6_prefix": "2600::/129"}, + {"ipv6_prefix": "2600:1f00::/24"}, + ], + } + with patch(URLOPEN_TARGET, return_value=mock_urlopen(payload)): + networks = get_public_ip_networks() + + assert networks == [ + IPv4Network("10.0.0.0/8"), + IPv6Network("2600:1f00::/24"), + ] diff --git a/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py b/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py new file mode 100644 index 0000000000..1685115755 --- /dev/null +++ b/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py @@ -0,0 +1,157 @@ +from unittest import mock + +from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CA_ID = "12345678-1234-1234-1234-123456789012" +CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}" + + +def _build_client(certificate_authorities, audit_config=None): + acmpca_client = mock.MagicMock() + acmpca_client.certificate_authorities = certificate_authorities + acmpca_client.audit_config = audit_config or {} + return acmpca_client + + +def _ca(key_algorithm: str, status: str = "ACTIVE"): + return CertificateAuthority( + arn=CA_ARN, + id=CA_ID, + region=AWS_REGION_US_EAST_1, + status=status, + type="SUBORDINATE", + usage_mode="GENERAL_PURPOSE", + key_algorithm=key_algorithm, + signing_algorithm="ML_DSA_65" if "ML_DSA" in key_algorithm else "SHA256WITHRSA", + ) + + +class Test_acmpca_certificate_authority_pqc_key_algorithm: + def test_no_cas(self): + acmpca_client = _build_client({}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + assert len(result) == 0 + + def test_ml_dsa_65(self): + acmpca_client = _build_client({CA_ARN: _ca("ML_DSA_65")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "ML_DSA_65" in result[0].status_extended + assert result[0].resource_id == CA_ID + assert result[0].resource_arn == CA_ARN + + def test_rsa_2048_fails(self): + acmpca_client = _build_client({CA_ARN: _ca("RSA_2048")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RSA_2048" in result[0].status_extended + + def test_deleted_ca_skipped(self): + acmpca_client = _build_client({CA_ARN: _ca("RSA_2048", status="DELETED")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 0 + + def test_configurable_allowlist(self): + acmpca_client = _build_client( + {CA_ARN: _ca("RSA_2048")}, + audit_config={"acmpca_pqc_key_algorithms": ["ML_DSA_65", "RSA_2048"]}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/acmpca/acmpca_service_test.py b/tests/providers/aws/services/acmpca/acmpca_service_test.py new file mode 100644 index 0000000000..024df5e5a2 --- /dev/null +++ b/tests/providers/aws/services/acmpca/acmpca_service_test.py @@ -0,0 +1,65 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from prowler.providers.aws.services.acmpca.acmpca_service import ( + ACMPCA, + CertificateAuthority, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CA_ID = "12345678-1234-1234-1234-123456789012" +CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListCertificateAuthorities": + return { + "CertificateAuthorities": [ + { + "Arn": CA_ARN, + "Status": "ACTIVE", + "Type": "SUBORDINATE", + "UsageMode": "GENERAL_PURPOSE", + "CertificateAuthorityConfiguration": { + "KeyAlgorithm": "ML_DSA_65", + "SigningAlgorithm": "ML_DSA_65", + }, + } + ] + } + if operation_name == "ListTags": + assert kwarg["CertificateAuthorityArn"] == CA_ARN + return {"Tags": [{"Key": "Environment", "Value": "test"}]} + return make_api_call(self, operation_name, kwarg) + + +class Test_ACMPCA_Service: + @mock_aws + def test_service(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + acmpca = ACMPCA(aws_provider) + assert acmpca.service == "acm-pca" + + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_certificate_authorities(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + acmpca = ACMPCA(aws_provider) + assert len(acmpca.certificate_authorities) == 1 + ca = acmpca.certificate_authorities[CA_ARN] + assert isinstance(ca, CertificateAuthority) + assert ca.id == CA_ID + assert ca.region == AWS_REGION_US_EAST_1 + assert ca.status == "ACTIVE" + assert ca.type == "SUBORDINATE" + assert ca.key_algorithm == "ML_DSA_65" + assert ca.signing_algorithm == "ML_DSA_65" + assert ca.tags == [{"Key": "Environment", "Value": "test"}] diff --git a/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py b/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py new file mode 100644 index 0000000000..6d460be32e --- /dev/null +++ b/tests/providers/aws/services/apigateway/apigateway_domain_name_pqc_tls_enabled/apigateway_domain_name_pqc_tls_enabled_test.py @@ -0,0 +1,243 @@ +from unittest import mock + +from moto import mock_aws + +from prowler.providers.aws.services.apigateway.apigateway_service import DomainName +from tests.providers.aws.utils import ( + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +DOMAIN_NAME = "api.example.com" +DOMAIN_ARN = f"arn:aws:apigateway:{AWS_REGION_US_EAST_1}::/domainnames/{DOMAIN_NAME}" + + +def _build_client(security_policy: str): + apigw_client = mock.MagicMock() + apigw_client.audit_config = {} + apigw_client.domain_names = [ + DomainName( + name=DOMAIN_NAME, + arn=DOMAIN_ARN, + region=AWS_REGION_US_EAST_1, + security_policy=security_policy, + ) + ] + return apigw_client + + +class Test_apigateway_domain_name_pqc_tls_enabled: + @mock_aws + def test_no_domains(self): + apigw_client = mock.MagicMock() + apigw_client.audit_config = {} + apigw_client.domain_names = [] + + 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.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_tls13_only_policy_fails_by_default(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_3_2025_09") + + 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.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "SecurityPolicy_TLS13_1_3_2025_09" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + assert result[0].resource_id == DOMAIN_NAME + assert result[0].resource_arn == DOMAIN_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_alternate_pq_policy(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + + 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.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "SecurityPolicy_TLS13_1_2_PQ_2025_09" in result[0].status_extended + ) + + @mock_aws + def test_legacy_tls_1_2(self): + apigw_client = _build_client("TLS_1_2") + + 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.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLS_1_2" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + + @mock_aws + def test_missing_security_policy(self): + apigw_client = _build_client("") + + 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.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "" in result[0].status_extended + + @mock_aws + def test_configurable_allowlist(self): + apigw_client = _build_client("TLS_1_2") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": [ + "TLS_1_2", + ] + } + + 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.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_null_config_uses_default_allowlist(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": None, + } + + 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.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + @mock_aws + def test_non_iterable_config_uses_default_allowlist(self): + apigw_client = _build_client("SecurityPolicy_TLS13_1_2_PQ_2025_09") + apigw_client.audit_config = { + "apigateway_pqc_tls_allowed_policies": 123, + } + + 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.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled.apigateway_client", + new=apigw_client, + ): + from prowler.providers.aws.services.apigateway.apigateway_domain_name_pqc_tls_enabled.apigateway_domain_name_pqc_tls_enabled import ( + apigateway_domain_name_pqc_tls_enabled, + ) + + check = apigateway_domain_name_pqc_tls_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/apigateway/apigateway_service_test.py b/tests/providers/aws/services/apigateway/apigateway_service_test.py index 9396693ec3..c153af2871 100644 --- a/tests/providers/aws/services/apigateway/apigateway_service_test.py +++ b/tests/providers/aws/services/apigateway/apigateway_service_test.py @@ -206,3 +206,26 @@ class Test_APIGateway_Service: assert list(apigateway.rest_apis[0].resources[1].resource_methods.values()) == [ "AWS_IAM" ] + + # Test APIGateway _get_domain_names + @mock_aws + def test_get_domain_names(self): + apigateway_client = client("apigateway", region_name=AWS_REGION_US_EAST_1) + + apigateway_client.create_domain_name( + domainName="api.example.com", + securityPolicy="SecurityPolicy_TLS13_1_3_2025_09", + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + apigateway = APIGateway(aws_provider) + + assert len(apigateway.domain_names) == 1 + domain = apigateway.domain_names[0] + assert domain.name == "api.example.com" + assert domain.region == AWS_REGION_US_EAST_1 + assert domain.security_policy == "SecurityPolicy_TLS13_1_3_2025_09" + assert ( + domain.arn + == f"arn:aws:apigateway:{AWS_REGION_US_EAST_1}::/domainnames/api.example.com" + ) diff --git a/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py new file mode 100644 index 0000000000..c6e5d7f756 --- /dev/null +++ b/tests/providers/aws/services/bedrock/bedrock_agent_role_least_privilege/bedrock_agent_role_least_privilege_test.py @@ -0,0 +1,275 @@ +from json import dumps +from unittest import mock + +import botocore +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +AGENT_ID = "test-agent-id" +AGENT_NAME = "test-agent-name" +AGENT_ARN = ( + f"arn:aws:bedrock:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:agent/{AGENT_ID}" +) +ROLE_NAME = "AmazonBedrockExecutionRoleForAgents_test" +ROLE_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/{ROLE_NAME}" +BOUNDARY_ARN = f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:policy/AgentBoundary" + +ASSUME_ROLE_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "bedrock.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], +} + +BOUNDARY_POLICY_DOCUMENT = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "bedrock:*", "Resource": "*"}], +} + +NARROW_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": ["arn:aws:s3:::my-rag-bucket/*"], + } + ], +} + +BROAD_INLINE_POLICY = { + "Version": "2012-10-17", + "Statement": [{"Effect": "Allow", "Action": "*", "Resource": "*"}], +} + + +# Mock both ListAgents and GetAgent at the botocore level. moto's bedrock-agent +# support is incomplete for our needs (GetAgent often doesn't echo back the +# role ARN we set), so we control the responses directly. We also need to keep +# IAM calls going to moto. +make_api_call = botocore.client.BaseClient._make_api_call + + +def _mock_bedrock_agent_factory(role_arn): + """Return a mock_make_api_call function that returns role_arn from GetAgent. + + Pass role_arn=None to simulate an agent whose role can't be resolved. + """ + + def _mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListAgents": + return { + "agentSummaries": [ + {"agentId": AGENT_ID, "agentName": AGENT_NAME}, + ] + } + if operation_name == "GetAgent": + return { + "agent": { + "agentId": AGENT_ID, + "agentName": AGENT_NAME, + "agentResourceRoleArn": role_arn, + } + } + if operation_name == "ListTagsForResource": + return {"tags": {}} + if operation_name == "ListPrompts": + return {"promptSummaries": []} + return make_api_call(self, operation_name, kwarg) + + return _mock_make_api_call + + +def _setup_role( + *, + attached_policy_arns=(), + inline_policies=None, + permissions_boundary=None, +): + """Create an IAM role in moto with the given configuration. Returns the role ARN.""" + iam = client("iam", region_name=AWS_REGION_US_EAST_1) + + if permissions_boundary: + iam.create_policy( + PolicyName="AgentBoundary", + PolicyDocument=dumps(BOUNDARY_POLICY_DOCUMENT), + ) + + create_kwargs = { + "RoleName": ROLE_NAME, + "AssumeRolePolicyDocument": dumps(ASSUME_ROLE_POLICY_DOCUMENT), + } + if permissions_boundary: + create_kwargs["PermissionsBoundary"] = permissions_boundary + iam.create_role(**create_kwargs) + + for policy_arn in attached_policy_arns: + iam.attach_role_policy(RoleName=ROLE_NAME, PolicyArn=policy_arn) + + for policy_name, policy_document in (inline_policies or {}).items(): + iam.put_role_policy( + RoleName=ROLE_NAME, + PolicyName=policy_name, + PolicyDocument=dumps(policy_document), + ) + + return ROLE_ARN + + +def _run_check(role_arn_for_get_agent): + """Build the IAM + BedrockAgent services, patch them in, run the check.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=_mock_bedrock_agent_factory(role_arn_for_get_agent), + ): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + return bedrock_agent_role_least_privilege().execute() + + +class Test_bedrock_agent_role_least_privilege: + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_no_agents(self): + """No agents in the account -> zero findings.""" + from prowler.providers.aws.services.bedrock.bedrock_service import BedrockAgent + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.bedrock_agent_client", + new=BedrockAgent(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege.iam_client", + new=IAM(aws_provider), + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_agent_role_least_privilege.bedrock_agent_role_least_privilege import ( + bedrock_agent_role_least_privilege, + ) + + assert bedrock_agent_role_least_privilege().execute() == [] + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_compliant(self): + """Narrow inline policy + boundary + no *FullAccess attached -> PASS.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "follows least privilege" in result[0].status_extended + assert result[0].resource_id == AGENT_ID + assert result[0].resource_arn == AGENT_ARN + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_full_access_attached(self): + """AmazonBedrockFullAccess attached -> FAIL.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AmazonBedrockFullAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants full access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_administrator_access_attached(self): + """AdministratorAccess attached (no FullAccess suffix) -> FAIL via doc-based admin check.""" + role_arn = _setup_role( + attached_policy_arns=("arn:aws:iam::aws:policy/AdministratorAccess",), + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "managed policy AdministratorAccess grants administrative access" + in result[0].status_extended + ) + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_resource_star_broad_action(self): + """Inline statement with Action:* on Resource:* -> FAIL.""" + role_arn = _setup_role( + inline_policies={"BroadAccess": BROAD_INLINE_POLICY}, + permissions_boundary=BOUNDARY_ARN, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "grants administrative access" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_no_permissions_boundary(self): + """Otherwise clean role but missing permissions boundary -> FAIL.""" + role_arn = _setup_role( + inline_policies={"NarrowAccess": NARROW_INLINE_POLICY}, + permissions_boundary=None, + ) + + result = _run_check(role_arn_for_get_agent=role_arn) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no permissions boundary configured" in result[0].status_extended + + @mock_aws(config={"iam": {"load_aws_managed_policies": True}}) + def test_agent_role_not_resolvable(self): + """role_arn returned by GetAgent doesn't match any IAM role -> FAIL.""" + result = _run_check( + role_arn_for_get_agent=f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/does-not-exist" + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "could not be resolved" in result[0].status_extended diff --git a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py index bf4f6381b9..d75d248c7c 100644 --- a/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py +++ b/tests/providers/aws/services/bedrock/bedrock_api_key_no_long_term_credentials/bedrock_api_key_no_long_term_credentials_test.py @@ -3,466 +3,197 @@ from unittest import mock from moto import mock_aws +from prowler.lib.check.models import Severity from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider +BEDROCK_SERVICE = "bedrock.amazonaws.com" + + +def _make_user(name="test_user"): + from prowler.providers.aws.services.iam.iam_service import User + + return User( + name=name, + arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{name}", + attached_policies=[], + inline_policies=[], + ) + + +def _make_credential( + user, + credential_id="test-credential-id", + expiration_delta_days=None, + service_name=BEDROCK_SERVICE, +): + from prowler.providers.aws.services.iam.iam_service import ServiceSpecificCredential + + expiration_date = ( + datetime.now(timezone.utc) + timedelta(days=expiration_delta_days) + if expiration_delta_days is not None + else None + ) + return ServiceSpecificCredential( + arn=( + f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/{user.name}/" + f"credential/{credential_id}" + ), + user=user, + status="Active", + create_date=datetime.now(timezone.utc), + service_user_name=None, + service_credential_alias=None, + expiration_date=expiration_date, + id=credential_id, + service_name=service_name, + region=AWS_REGION_US_EAST_1, + ) + + +def _run_check(credentials): + from prowler.providers.aws.services.iam.iam_service import IAM + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam = IAM(aws_provider) + iam.service_specific_credentials = credentials + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", + new=iam, + ), + ): + from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( + bedrock_api_key_no_long_term_credentials, + ) + + check = bedrock_api_key_no_long_term_credentials() + return check.execute() + class Test_bedrock_api_key_no_long_term_credentials: @mock_aws def test_no_bedrock_api_keys(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=IAM(aws_provider), - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert _run_check([]) == [] @mock_aws - def test_bedrock_api_key_with_future_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_short_expiration_key_fails_high(self): + # Per AWS guidance, every active long-term key is a finding regardless of + # how soon it expires. Short remaining lifetime does not downgrade severity. + credential = _make_credential(_make_user(), expiration_delta_days=30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "will expire in" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_critical_expiration_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_active_long_expiration_key_fails_high(self): + credential = _make_credential(_make_user(), expiration_delta_days=365) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with very far future expiration date (>10000 days) - expiration_date = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "FAIL" - assert "never expires" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 - assert check.Severity == "critical" + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.high + assert "is active and will expire in" in result[0].status_extended @mock_aws - def test_bedrock_api_key_with_expired_date(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_never_expires_key_fails_critical(self): + # >10000 days approximates AWS's "no expiration" sentinel (~100 years). + credential = _make_credential(_make_user(), expiration_delta_days=15000) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with past expiration date - expiration_date = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 1 - assert result[0].status == "PASS" - assert "has expired" in result[0].status_extended - assert "test-credential-id" in result[0].status_extended - assert "test_user" in result[0].status_extended - assert result[0].resource_id == "test-credential-id" - assert result[0].region == AWS_REGION_US_EAST_1 + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].check_metadata.Severity == Severity.critical + assert "configured to never expire" in result[0].status_extended + assert "short-term Bedrock API keys" in result[0].status_extended @mock_aws - def test_bedrock_api_key_without_expiration_date_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM + def test_already_expired_key_passes(self): + credential = _make_credential(_make_user(), expiration_delta_days=-30) + result = _run_check([credential]) - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential without expiration date (should be ignored) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=None, # No expiration date - should be ignored - id="test-credential-id", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has already expired" in result[0].status_extended @mock_aws - def test_non_bedrock_api_key_ignored(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, - ) - - # Create a mock user - mock_user = User( - name="test_user", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential for a different service - expiration_date = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user/credential/test-credential-id", - user=mock_user, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date, - id="test-credential-id", - service_name="codecommit.amazonaws.com", # Different service - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [mock_credential] - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, - ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, - ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) - - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() - - assert len(result) == 0 + def test_key_without_expiration_date_ignored(self): + credential = _make_credential(_make_user(), expiration_delta_days=None) + assert _run_check([credential]) == [] @mock_aws - def test_multiple_bedrock_api_keys_mixed_scenarios(self): - from prowler.providers.aws.services.iam.iam_service import IAM - - aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - iam = IAM(aws_provider) - - # Mock service-specific credentials - from prowler.providers.aws.services.iam.iam_service import ( - ServiceSpecificCredential, - User, + def test_non_bedrock_service_ignored(self): + credential = _make_credential( + _make_user(), + expiration_delta_days=30, + service_name="codecommit.amazonaws.com", ) + assert _run_check([credential]) == [] - # Create mock users - mock_user1 = User( - name="test_user1", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1", - attached_policies=[], - inline_policies=[], + @mock_aws + def test_mixed_scenarios(self): + user1, user2, user3 = ( + _make_user("u1"), + _make_user("u2"), + _make_user("u3"), ) - - mock_user2 = User( - name="test_user2", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2", - attached_policies=[], - inline_policies=[], - ) - - mock_user3 = User( - name="test_user3", - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3", - attached_policies=[], - inline_policies=[], - ) - - # Create a mock service-specific credential with future expiration date - expiration_date1 = datetime.now(timezone.utc) + timedelta(days=30) - mock_credential1 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user1/credential/test-credential-id-1", - user=mock_user1, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date1, - id="test-credential-id-1", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with critical expiration date - expiration_date2 = datetime.now(timezone.utc) + timedelta(days=15000) - mock_credential2 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user2/credential/test-credential-id-2", - user=mock_user2, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date2, - id="test-credential-id-2", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - # Create a mock service-specific credential with expired date - expiration_date3 = datetime.now(timezone.utc) - timedelta(days=30) - mock_credential3 = ServiceSpecificCredential( - arn=f"arn:aws:iam:{AWS_REGION_US_EAST_1}:123456789012:user/test_user3/credential/test-credential-id-3", - user=mock_user3, - status="Active", - create_date=datetime.now(timezone.utc), - service_user_name=None, - service_credential_alias=None, - expiration_date=expiration_date3, - id="test-credential-id-3", - service_name="bedrock.amazonaws.com", - region=AWS_REGION_US_EAST_1, - ) - - iam.service_specific_credentials = [ - mock_credential1, - mock_credential2, - mock_credential3, + credentials = [ + _make_credential(user1, "active-key", expiration_delta_days=191), + _make_credential(user2, "never-key", expiration_delta_days=15000), + _make_credential(user3, "expired-key", expiration_delta_days=-30), ] + result = _run_check(credentials) - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=aws_provider, + assert len(result) == 3 + by_id = {r.resource_id: r for r in result} + + assert by_id["active-key"].status == "FAIL" + assert by_id["active-key"].check_metadata.Severity == Severity.high + + assert by_id["never-key"].status == "FAIL" + assert by_id["never-key"].check_metadata.Severity == Severity.critical + + assert by_id["expired-key"].status == "PASS" + + @mock_aws + def test_severity_does_not_leak_never_then_active(self): + """Regression: a never-expires key processed before an active key must + not bleed `critical` severity into the active finding.""" + credentials = [ + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 ), - mock.patch( - "prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials.iam_client", - new=iam, + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 ), - ): - from prowler.providers.aws.services.bedrock.bedrock_api_key_no_long_term_credentials.bedrock_api_key_no_long_term_credentials import ( - bedrock_api_key_no_long_term_credentials, - ) + ] + result = _run_check(credentials) - check = bedrock_api_key_no_long_term_credentials() - result = check.execute() + by_id = {r.resource_id: r for r in result} + assert by_id["never-key"].check_metadata.Severity == Severity.critical + assert by_id["active-key"].check_metadata.Severity == Severity.high - assert len(result) == 3 + @mock_aws + def test_severity_does_not_leak_active_then_never(self): + """Regression: same as above with the reverse iteration order.""" + credentials = [ + _make_credential( + _make_user("u-active"), "active-key", expiration_delta_days=191 + ), + _make_credential( + _make_user("u-never"), "never-key", expiration_delta_days=15000 + ), + ] + result = _run_check(credentials) - # Check the credential with future expiration date (FAIL) - fail_result1 = next( - r for r in result if r.resource_id == "test-credential-id-1" - ) - assert fail_result1.status == "FAIL" - assert "will expire in" in fail_result1.status_extended - assert "test-credential-id-1" in fail_result1.status_extended - assert "test_user1" in fail_result1.status_extended - - # Check the credential with critical expiration date (FAIL) - fail_result2 = next( - r for r in result if r.resource_id == "test-credential-id-2" - ) - assert fail_result2.status == "FAIL" - assert "never expires" in fail_result2.status_extended - assert "test-credential-id-2" in fail_result2.status_extended - assert "test_user2" in fail_result2.status_extended - - # Check the credential with expired date (PASS) - pass_result = next( - r for r in result if r.resource_id == "test-credential-id-3" - ) - assert pass_result.status == "PASS" - assert "has expired" in pass_result.status_extended - assert "test-credential-id-3" in pass_result.status_extended - assert "test_user3" in pass_result.status_extended + by_id = {r.resource_id: r for r in result} + assert by_id["active-key"].check_metadata.Severity == Severity.high + assert by_id["never-key"].check_metadata.Severity == Severity.critical diff --git a/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py b/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py new file mode 100644 index 0000000000..5170d5538d --- /dev/null +++ b/tests/providers/aws/services/cloudfront/cloudfront_distributions_pqc_tls_enabled/cloudfront_distributions_pqc_tls_enabled_test.py @@ -0,0 +1,158 @@ +import sys +from unittest import mock + +from prowler.providers.aws.services.cloudfront.cloudfront_service import ( + Distribution, + Origin, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +DISTRIBUTION_ID = "E27LVI50CSW06W" +DISTRIBUTION_ARN = ( + f"arn:aws:cloudfront::{AWS_ACCOUNT_NUMBER}:distribution/{DISTRIBUTION_ID}" +) +REGION = "us-east-1" +CHECK_MODULE = "prowler.providers.aws.services.cloudfront.cloudfront_distributions_pqc_tls_enabled.cloudfront_distributions_pqc_tls_enabled" +CLIENT_MODULE = "prowler.providers.aws.services.cloudfront.cloudfront_client" + + +def _clear_cloudfront_modules(): + sys.modules.pop(CHECK_MODULE, None) + sys.modules.pop(CLIENT_MODULE, None) + + +def _build_distribution( + *, + minimum_protocol_version: str, + default_certificate: bool = False, +): + return Distribution( + arn=DISTRIBUTION_ARN, + id=DISTRIBUTION_ID, + region=REGION, + origins=[ + Origin( + id="o1", + domain_name="origin.example.com", + origin_protocol_policy="https-only", + origin_ssl_protocols=["TLSv1.2"], + ) + ], + origin_failover=False, + minimum_protocol_version=minimum_protocol_version, + default_certificate=default_certificate, + ) + + +def _build_client(distributions: dict, audit_config: dict | None = None): + cloudfront_client = mock.MagicMock() + cloudfront_client.distributions = distributions + cloudfront_client.audit_config = audit_config or {} + return cloudfront_client + + +def _execute_check(cloudfront_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + _clear_cloudfront_modules() + + try: + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudfront.cloudfront_service.CloudFront", + return_value=cloudfront_client, + ), + ): + from prowler.providers.aws.services.cloudfront.cloudfront_distributions_pqc_tls_enabled.cloudfront_distributions_pqc_tls_enabled import ( + cloudfront_distributions_pqc_tls_enabled, + ) + + check = cloudfront_distributions_pqc_tls_enabled() + return check.execute() + finally: + _clear_cloudfront_modules() + + +class Test_cloudfront_distributions_pqc_tls_enabled: + def test_no_distributions(self): + cloudfront_client = _build_client({}) + + result = _execute_check(cloudfront_client) + + assert len(result) == 0 + + def test_pq_policy_tls13_2025(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.3_2025" + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "TLSv1.3_2025" in result[0].status_extended + assert result[0].resource_id == DISTRIBUTION_ID + assert result[0].resource_arn == DISTRIBUTION_ARN + + def test_classical_tls12_2021(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.2_2021" + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TLSv1.2_2021" in result[0].status_extended + assert "not in the post-quantum allowlist" in result[0].status_extended + + def test_default_cloudfront_certificate(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1", + default_certificate=True, + ) + } + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "default CloudFront certificate" in result[0].status_extended + + def test_configurable_allowlist(self): + cloudfront_client = _build_client( + { + DISTRIBUTION_ID: _build_distribution( + minimum_protocol_version="TLSv1.2_2021" + ) + }, + audit_config={ + "cloudfront_pqc_min_protocol_versions": [ + "TLSv1.3_2025", + "TLSv1.2_2021", + ] + }, + ) + + result = _execute_check(cloudfront_client) + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/cloudfront/cloudfront_service_test.py b/tests/providers/aws/services/cloudfront/cloudfront_service_test.py index 4c0a9b349e..5dd6f712a7 100644 --- a/tests/providers/aws/services/cloudfront/cloudfront_service_test.py +++ b/tests/providers/aws/services/cloudfront/cloudfront_service_test.py @@ -64,6 +64,7 @@ def example_distribution_config(ref): "ViewerCertificate": { "SSLSupportMethod": "static-ip", "Certificate": "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012", + "MinimumProtocolVersion": "TLSv1.3_2025", }, "Comment": "an optional comment that's not actually optional", "Enabled": False, @@ -234,6 +235,7 @@ class Test_CloudFront_Service: ] SSL_SUPPORT_METHOD = SSLSupportMethod.sni_only CERTIFICATE = "arn:aws:acm:us-east-1:123456789012:certificate/12345678-1234-1234-1234-123456789012" + MINIMUM_PROTOCOL_VERSION = "TLSv1.3_2025" cloudfront = mock.MagicMock cloudfront.distributions = { @@ -249,6 +251,7 @@ class Test_CloudFront_Service: tags=TAGS, ssl_support_method=SSL_SUPPORT_METHOD, certificate=CERTIFICATE, + minimum_protocol_version=MINIMUM_PROTOCOL_VERSION, ) } @@ -288,6 +291,10 @@ class Test_CloudFront_Service: == DEFAULT_CACHE_CONFIG.field_level_encryption_id ) assert cloudfront.distributions[DISTRIBUTION_ID].tags == TAGS + assert ( + cloudfront.distributions[DISTRIBUTION_ID].minimum_protocol_version + == MINIMUM_PROTOCOL_VERSION + ) def test_get_log_delivery_sources_with_active_delivery(self): from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py index 66c2099843..928ff2b47c 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_acls_alarm_configured/cloudwatch_changes_to_network_acls_alarm_configured_test.py @@ -674,3 +674,185 @@ class Test_cloudwatch_changes_to_network_acls_alarm_configured: result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ReplaceNetworkAclAssociation) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = CreateNetworkAcl) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_acls_alarm_configured.cloudwatch_changes_to_network_acls_alarm_configured import ( + cloudwatch_changes_to_network_acls_alarm_configured, + ) + + check = cloudwatch_changes_to_network_acls_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py index afe0f7d3ce..50c8663437 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_gateways_alarm_configured/cloudwatch_changes_to_network_gateways_alarm_configured_test.py @@ -616,3 +616,95 @@ class Test_cloudwatch_changes_to_network_gateways_alarm_configured: ) assert result[0].region == AWS_REGION_US_EAST_1 assert result[0].resource_tags == [{}] + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = CreateCustomerGateway) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_gateways_alarm_configured.cloudwatch_changes_to_network_gateways_alarm_configured import ( + cloudwatch_changes_to_network_gateways_alarm_configured, + ) + + check = cloudwatch_changes_to_network_gateways_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py index 7ec6e32c56..929793eb34 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_network_route_tables_alarm_configured/cloudwatch_changes_to_network_route_tables_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_network_route_tables_alarm_configured: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = DisassociateRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DeleteRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = ReplaceRoute) || ($.eventName = CreateRouteTable) || ($.eventName = CreateRoute) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = ec2.amazonaws.com) && ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DisassociateRouteTable) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_network_route_tables_alarm_configured.cloudwatch_changes_to_network_route_tables_alarm_configured import ( + cloudwatch_changes_to_network_route_tables_alarm_configured, + ) + + check = cloudwatch_changes_to_network_route_tables_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py index 31f27030ff..57d33cb3fe 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_changes_to_vpcs_alarm_configured/cloudwatch_changes_to_vpcs_alarm_configured_test.py @@ -596,3 +596,185 @@ class Test_cloudwatch_changes_to_vpcs_alarm_configured: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = EnableVpcClassicLink) || ($.eventName = DisableVpcClassicLink) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = ModifyVpcAttribute) || ($.eventName = DeleteVpc) || ($.eventName = CreateVpc) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_substring_only_no_match(self): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_changes_to_vpcs_alarm_configured.cloudwatch_changes_to_vpcs_alarm_configured import ( + cloudwatch_changes_to_vpcs_alarm_configured, + ) + + check = cloudwatch_changes_to_vpcs_alarm_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No CloudWatch log groups found with metric filters or alarms associated." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py index 30982553b2..62ca14d4ba 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled_test.py @@ -665,3 +665,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_c result = check.execute() assert len(result) == 0 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = config.amazonaws.com) && (($.eventName = PutConfigurationRecorder) || ($.eventName = PutDeliveryChannel) || ($.eventName = DeleteDeliveryChannel) || ($.eventName = StopConfigurationRecorder)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled import ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py index fcb98fda3e..3b555bf05b 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled/cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_c == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = StopLogging) || ($.eventName = StartLogging) || ($.eventName = DeleteTrail) || ($.eventName = UpdateTrail) || ($.eventName = CreateTrail) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled.cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled import ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled, + ) + + check = ( + cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py index 66b9d8116b..201be76f6d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_authentication_failures/cloudwatch_log_metric_filter_authentication_failures_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_authentication_failures: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.errorMessage = Failed authentication) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_authentication_failures.cloudwatch_log_metric_filter_authentication_failures import ( + cloudwatch_log_metric_filter_authentication_failures, + ) + + check = cloudwatch_log_metric_filter_authentication_failures() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py index 3aaf475cbe..534c50c906 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_aws_organizations_changes/cloudwatch_log_metric_filter_aws_organizations_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_aws_organizations_changes: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = organizations.amazonaws.com) && ($.eventName = UpdatePolicy) || ($.eventName = UpdateOrganizationalUnit) || ($.eventName = RemoveAccountFromOrganization) || ($.eventName = MoveAccount) || ($.eventName = DisablePolicyType) || ($.eventName = DetachPolicy) || ($.eventName = LeaveOrganization) || ($.eventName = InviteAccountToOrganization) || ($.eventName = EnablePolicyType) || ($.eventName = EnableAllFeatures) || ($.eventName = DeletePolicy) || ($.eventName = DeleteOrganizationalUnit) || ($.eventName = DeleteOrganization) || ($.eventName = DeclineHandshake) || ($.eventName = CreatePolicy) || ($.eventName = CreateOrganizationalUnit) || ($.eventName = CreateOrganization) || ($.eventName = CreateAccount) || ($.eventName = CancelHandshake) || ($.eventName = AttachPolicy) || ($.eventName = AcceptHandshake) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_aws_organizations_changes.cloudwatch_log_metric_filter_aws_organizations_changes import ( + cloudwatch_log_metric_filter_aws_organizations_changes, + ) + + check = cloudwatch_log_metric_filter_aws_organizations_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py index 7eb1cae331..f13263cf9f 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk/cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk_test.py @@ -610,3 +610,97 @@ class Test_cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = kms.amazonaws.com) && (($.eventName = ScheduleKeyDeletion) || ($.eventName = DisableKey)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk.cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk import ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk, + ) + + check = ( + cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk() + ) + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py index 7d2d631f11..aa88f5164d 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes/cloudwatch_log_metric_filter_for_s3_bucket_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_for_s3_bucket_policy_changes: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventSource = s3.amazonaws.com) && (($.eventName = DeleteBucketReplication) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketPolicy) || ($.eventName = PutBucketReplication) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketAcl)) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes.cloudwatch_log_metric_filter_for_s3_bucket_policy_changes import ( + cloudwatch_log_metric_filter_for_s3_bucket_policy_changes, + ) + + check = cloudwatch_log_metric_filter_for_s3_bucket_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py index 06e36688d6..fd3e86d313 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_policy_changes/cloudwatch_log_metric_filter_policy_changes_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DetachGroupPolicy) || ($.eventName = AttachGroupPolicy) || ($.eventName = DetachUserPolicy) || ($.eventName = AttachUserPolicy) || ($.eventName = DetachRolePolicy) || ($.eventName = AttachRolePolicy) || ($.eventName = DeletePolicyVersion) || ($.eventName = CreatePolicyVersion) || ($.eventName = DeletePolicy) || ($.eventName = CreatePolicy) || ($.eventName = PutUserPolicy) || ($.eventName = PutRolePolicy) || ($.eventName = PutGroupPolicy) || ($.eventName = DeleteUserPolicy) || ($.eventName = DeleteRolePolicy) || ($.eventName = DeleteGroupPolicy) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_policy_changes.cloudwatch_log_metric_filter_policy_changes import ( + cloudwatch_log_metric_filter_policy_changes, + ) + + check = cloudwatch_log_metric_filter_policy_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py index ede85e8e6e..35dee7b516 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_security_group_changes/cloudwatch_log_metric_filter_security_group_changes_test.py @@ -599,3 +599,95 @@ class Test_cloudwatch_log_metric_filter_unauthorized_api_calls: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.eventName = DeleteSecurityGroup) || ($.eventName = CreateSecurityGroup) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = AuthorizeSecurityGroupIngress) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_security_group_changes.cloudwatch_log_metric_filter_security_group_changes import ( + cloudwatch_log_metric_filter_security_group_changes, + ) + + check = cloudwatch_log_metric_filter_security_group_changes() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py index df67472cdd..6944860d75 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_log_metric_filter_sign_in_without_mfa/cloudwatch_log_metric_filter_sign_in_without_mfa_test.py @@ -596,3 +596,95 @@ class Test_cloudwatch_log_metric_filter_sign_in_without_mfa: == f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*" ) assert result[0].region == AWS_REGION_US_EAST_1 + + @mock_aws + def test_cloudwatch_trail_with_log_group_with_metric_and_alarm_reversed_clauses( + self, + ): + cloudtrail_client = client("cloudtrail", region_name=AWS_REGION_US_EAST_1) + cloudwatch_client = client("cloudwatch", region_name=AWS_REGION_US_EAST_1) + logs_client = client("logs", region_name=AWS_REGION_US_EAST_1) + s3_client = client("s3", region_name=AWS_REGION_US_EAST_1) + s3_client.create_bucket(Bucket="test") + logs_client.create_log_group(logGroupName="/log-group/test") + cloudtrail_client.create_trail( + Name="test_trail", + S3BucketName="test", + CloudWatchLogsLogGroupArn=f"arn:aws:logs:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:log-group:/log-group/test:*", + ) + logs_client.put_metric_filter( + logGroupName="/log-group/test", + filterName="test-filter", + filterPattern="{ ($.additionalEventData.MFAUsed != Yes) && ($.eventName = ConsoleLogin) }", + metricTransformations=[ + { + "metricName": "my-metric", + "metricNamespace": "my-namespace", + "metricValue": "$.value", + } + ], + ) + cloudwatch_client.put_metric_alarm( + AlarmName="test-alarm", + MetricName="my-metric", + Namespace="my-namespace", + Period=10, + EvaluationPeriods=5, + Statistic="Average", + Threshold=2, + ComparisonOperator="GreaterThanThreshold", + ActionsEnabled=True, + ) + + from prowler.providers.aws.services.cloudtrail.cloudtrail_service import ( + Cloudtrail, + ) + from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( + CloudWatch, + Logs, + ) + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + from prowler.providers.common.models import Audit_Metadata + + aws_provider.audit_metadata = Audit_Metadata( + services_scanned=0, + expected_checks=["cloudwatch_log_group_no_secrets_in_logs"], + completed_checks=0, + audit_progress=0, + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.logs_client", + new=Logs(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_client", + new=CloudWatch(aws_provider), + ), + mock.patch( + "prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudtrail_client", + new=Cloudtrail(aws_provider), + ), + ): + from prowler.providers.aws.services.cloudwatch.cloudwatch_log_metric_filter_sign_in_without_mfa.cloudwatch_log_metric_filter_sign_in_without_mfa import ( + cloudwatch_log_metric_filter_sign_in_without_mfa, + ) + + check = cloudwatch_log_metric_filter_sign_in_without_mfa() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "CloudWatch log group /log-group/test found with metric filter test-filter and alarms set." + ) diff --git a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py index 8ae7ed980f..33d4bde7d7 100644 --- a/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py +++ b/tests/providers/aws/services/cloudwatch/cloudwatch_service_test.py @@ -1,3 +1,4 @@ +import pytest from boto3 import client from moto import mock_aws @@ -5,6 +6,9 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_service import ( CloudWatch, Logs, ) +from prowler.providers.aws.services.cloudwatch.lib.metric_filters import ( + build_metric_filter_pattern, +) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, AWS_REGION_US_EAST_1, @@ -216,3 +220,13 @@ class Test_CloudWatch_Service: assert logs.log_groups[arn].kms_id == "test_kms_id" assert logs.log_groups[arn].region == AWS_REGION_US_EAST_1 assert logs.log_groups[arn].tags == [{}] + + +class Test_build_metric_filter_pattern: + @pytest.mark.parametrize("bad_operator", ["==", "~=", "<", "<>", ">=", ""]) + def test_rejects_unsupported_operator(self, bad_operator): + with pytest.raises(ValueError, match="unsupported operator"): + build_metric_filter_pattern( + event_names=["ConsoleLogin"], + extra_clauses=[("errorMessage", bad_operator, "Failed authentication")], + ) diff --git a/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py b/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py index d0d1057307..bf27c29632 100644 --- a/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py +++ b/tests/providers/aws/services/codepipeline/codepipeline_project_repo_private/codepipeline_project_repo_private_test.py @@ -1,3 +1,4 @@ +import ssl import urllib.error import urllib.request from unittest import mock @@ -67,7 +68,7 @@ class Test_codepipeline_project_repo_private: "Connection": {"ProviderType": "GitHub"} } - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): raise urllib.error.HTTPError( url="", code=404, msg="", hdrs={}, fp=None ) @@ -145,7 +146,7 @@ class Test_codepipeline_project_repo_private: mock_response.getcode.return_value = 200 mock_response.geturl.return_value = f"https://github.com/{repo_id}" - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): if "github.com" in req.get_full_url(): return mock_response raise urllib.error.HTTPError( @@ -172,6 +173,145 @@ class Test_codepipeline_project_repo_private: assert result[0].resource_tags == [] assert result[0].region == AWS_REGION + def test_pipeline_repo_ssl_verification_failure(self): + """Test that a TLS certificate verification failure is treated as private. + When the probe cannot verify the server certificate (e.g. a MITM + presenting a forged certificate), the repository must not be reported + as public. + """ + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION]), + ): + codepipeline_client = mock.MagicMock + pipeline_name = "test-pipeline" + pipeline_arn = f"arn:aws:codepipeline:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:pipeline/{pipeline_name}" + connection_arn = f"arn:aws:codestar-connections:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:connection/test-connection" + repo_id = "prowler-cloud/prowler-private" + + codepipeline_client.pipelines = { + pipeline_arn: Pipeline( + name=pipeline_name, + arn=pipeline_arn, + region=AWS_REGION, + source=Source( + type="CodeStarSourceConnection", + repository_id=repo_id, + configuration={ + "FullRepositoryId": repo_id, + "ConnectionArn": connection_arn, + }, + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_service.CodePipeline", + codepipeline_client, + ), + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private.codepipeline_client", + codepipeline_client, + ), + mock.patch("boto3.client") as mock_client, + mock.patch("urllib.request.urlopen") as mock_urlopen, + ): + mock_connection = mock_client.return_value + mock_connection.get_connection.return_value = { + "Connection": {"ProviderType": "GitHub"} + } + + def mock_urlopen_side_effect(req, **kwargs): + raise ssl.SSLError("certificate verify failed") + + mock_urlopen.side_effect = mock_urlopen_side_effect + + from prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private import ( + codepipeline_project_repo_private, + ) + + check = codepipeline_project_repo_private() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"CodePipeline {pipeline_name} source repository {repo_id} is private." + ) + + def test_pipeline_repo_probe_verifies_certificate_and_sets_timeout(self): + """Test the probe never disables certificate verification and sets a timeout. + Regression test for the SSL verification bypass: the check must not use + an unverified SSL context, and every request must carry a timeout. + """ + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_aws_provider([AWS_REGION]), + ): + codepipeline_client = mock.MagicMock + pipeline_name = "test-pipeline" + pipeline_arn = f"arn:aws:codepipeline:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:pipeline/{pipeline_name}" + connection_arn = f"arn:aws:codestar-connections:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:connection/test-connection" + repo_id = "prowler-cloud/prowler-private" + + codepipeline_client.pipelines = { + pipeline_arn: Pipeline( + name=pipeline_name, + arn=pipeline_arn, + region=AWS_REGION, + source=Source( + type="CodeStarSourceConnection", + repository_id=repo_id, + configuration={ + "FullRepositoryId": repo_id, + "ConnectionArn": connection_arn, + }, + ), + tags=[], + ) + } + + with ( + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_service.CodePipeline", + codepipeline_client, + ), + mock.patch( + "prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private.codepipeline_client", + codepipeline_client, + ), + mock.patch("boto3.client") as mock_client, + mock.patch("urllib.request.urlopen") as mock_urlopen, + mock.patch("ssl._create_unverified_context") as mock_unverified, + ): + mock_connection = mock_client.return_value + mock_connection.get_connection.return_value = { + "Connection": {"ProviderType": "GitHub"} + } + + def mock_urlopen_side_effect(req, **kwargs): + raise urllib.error.HTTPError( + url="", code=404, msg="", hdrs={}, fp=None + ) + + mock_urlopen.side_effect = mock_urlopen_side_effect + + from prowler.providers.aws.services.codepipeline.codepipeline_project_repo_private.codepipeline_project_repo_private import ( + HTTP_TIMEOUT, + codepipeline_project_repo_private, + ) + + check = codepipeline_project_repo_private() + check.execute() + + mock_unverified.assert_not_called() + assert mock_urlopen.call_count > 0 + for call in mock_urlopen.call_args_list: + assert call.kwargs.get("timeout") == HTTP_TIMEOUT + def test_pipeline_public_gitlab_repo(self): """Test detection of public GitLab repository in CodePipeline. Tests that the check correctly identifies a public GitLab repository @@ -225,7 +365,7 @@ class Test_codepipeline_project_repo_private: mock_response.getcode.return_value = 200 mock_response.geturl.return_value = f"https://gitlab.com/{repo_id}" - def mock_urlopen_side_effect(req, context=None): + def mock_urlopen_side_effect(req, **kwargs): if "gitlab.com" in req.get_full_url(): return mock_response raise urllib.error.HTTPError( diff --git a/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py new file mode 100644 index 0000000000..f2acf555d1 --- /dev/null +++ b/tests/providers/aws/services/config/config_delegated_admin_and_org_aggregator_all_regions/config_delegated_admin_and_org_aggregator_all_regions_test.py @@ -0,0 +1,491 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + + +AGG_ARN_TEMPLATE = ( + "arn:aws:config:{region}:" + AWS_ACCOUNT_NUMBER + ":config-aggregator/{name}" +) + + +def _aggregator_payload( + name, region, *, org_aware=True, all_regions=True, aws_regions=None +): + payload = { + "ConfigurationAggregatorName": name, + "ConfigurationAggregatorArn": AGG_ARN_TEMPLATE.format(region=region, name=name), + } + if org_aware: + org_source = { + "RoleArn": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/AWSConfigRoleForOrganizations", + "AllAwsRegions": all_regions, + } + if not all_regions and aws_regions: + org_source["AwsRegions"] = aws_regions + payload["OrganizationAggregationSource"] = org_source + return payload + + +def make_mock_no_aggregators_no_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return {"ConfigurationAggregators": []} + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregator_not_org_aware(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "legacy-agg", + AWS_REGION_EU_WEST_1, + org_aware=False, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_org_aggregator_not_all_regions_with_admin(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "partial-org-agg", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=False, + aws_regions=[AWS_REGION_EU_WEST_1], + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_full_pass(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + return { + "DelegatedAdministrators": [ + { + "Id": "123456789012", + "Arn": f"arn:aws:organizations::{AWS_ACCOUNT_NUMBER}:account/o-abc123/123456789012", + "Email": "admin@example.com", + "Name": "Security", + "Status": "ACTIVE", + "JoinedMethod": "CREATED", + } + ] + } + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_access_denied_on_orgs(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: organizations:ListDelegatedAdministrators", + } + }, + operation_name, + ) + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_access_denied(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "denied", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_other_client_error(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InternalServerError", + "Message": "boom", + } + }, + operation_name, + ) + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_aggregators_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + raise RuntimeError("simulated transient error") + if operation_name == "ListDelegatedAdministrators": + return {"DelegatedAdministrators": []} + return orig(self, operation_name, api_params) + + return _mock + + +def make_mock_delegated_admins_unexpected_exception(): + def _mock(self, operation_name, api_params): + if operation_name == "DescribeConfigurationAggregators": + return { + "ConfigurationAggregators": [ + _aggregator_payload( + "org-aggregator", + AWS_REGION_EU_WEST_1, + org_aware=True, + all_regions=True, + ) + ] + } + if operation_name == "ListDelegatedAdministrators": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + return _mock + + +class Test_config_delegated_admin_and_org_aggregator_all_regions: + @mock_aws + def test_no_aggregators_no_admin(self): + """Test when no aggregators exist in any region and no delegated admin is set.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_no_aggregators_no_admin(), + ): + aws_provider = set_mocked_aws_provider( + [AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1] + ) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "no Organization Aggregator configured in any region" + in result[0].status_extended + ) + assert ( + "no delegated administrator registered for config.amazonaws.com" + in result[0].status_extended + ) + + @mock_aws + def test_aggregator_not_org_aware(self): + """Test when an aggregator exists but is not an organization aggregator.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregator_not_org_aware(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "is not an organization aggregator" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_org_aggregator_not_all_regions_with_admin(self): + """Test org aggregator that doesn't cover all AWS regions (delegated admin set).""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_org_aggregator_not_all_regions_with_admin(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "does not cover all AWS regions" in eu_west_1_result.status_extended + ) + + @mock_aws + def test_full_pass(self): + """Test PASS: delegated admin set and org aggregator covering all AWS regions.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_full_pass(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert ( + "is an organization aggregator covering all AWS regions" + in eu_west_1_result.status_extended + ) + assert "delegated admin configured" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == AGG_ARN_TEMPLATE.format( + region=AWS_REGION_EU_WEST_1, name="org-aggregator" + ) + + @mock_aws + def test_access_denied_on_organizations(self): + """Test that AccessDenied on Organizations is reported as unknown admin state.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_access_denied_on_orgs(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.config.config_service import Config + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions.config_client", + new=Config(aws_provider), + ), + ): + from prowler.providers.aws.services.config.config_delegated_admin_and_org_aggregator_all_regions.config_delegated_admin_and_org_aggregator_all_regions import ( + config_delegated_admin_and_org_aggregator_all_regions, + ) + + check = config_delegated_admin_and_org_aggregator_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + # The check still runs; aggregator coverage is satisfied but the + # delegated-admin status is unknown, so it must FAIL. + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status for config.amazonaws.com could not be determined" + in eu_west_1_result.status_extended + ) + + @mock_aws + def test_aggregators_access_denied(self): + """AccessDenied on DescribeConfigurationAggregators is swallowed: no aggregators recorded for that region.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_access_denied(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_other_client_error(self): + """Non-access ClientError on DescribeConfigurationAggregators is logged at error level.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_other_client_error(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_aggregators_unexpected_exception(self): + """Non-ClientError on DescribeConfigurationAggregators is caught by bare except.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_aggregators_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.aggregators == {} + + @mock_aws + def test_delegated_admins_unexpected_exception(self): + """Non-ClientError on ListDelegatedAdministrators must still set lookup_failed.""" + with patch( + "botocore.client.BaseClient._make_api_call", + new=make_mock_delegated_admins_unexpected_exception(), + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + from prowler.providers.aws.services.config.config_service import Config + + service = Config(aws_provider) + assert service.delegated_administrators_lookup_failed is True + assert service.delegated_administrators == [] diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py new file mode 100644 index 0000000000..d21d4e20b7 --- /dev/null +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_service_test.py @@ -0,0 +1,92 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + RolesAnywhere, + TrustAnchor, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +TA_ID = "11111111-2222-3333-4444-555555555555" +TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}" +PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/abc" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListTrustAnchors": + return { + "trustAnchors": [ + { + "trustAnchorArn": TA_ARN, + "trustAnchorId": TA_ID, + "name": "pqc-trust", + "enabled": True, + "source": { + "sourceType": "AWS_ACM_PCA", + "sourceData": {"acmPcaArn": PCA_ARN}, + }, + } + ] + } + if operation_name == "ListTagsForResource": + return {"tags": [{"key": "Environment", "value": "test"}]} + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_tags_failure(self, operation_name, kwarg): + if operation_name == "ListTagsForResource": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "Access denied", + } + }, + operation_name, + ) + return mock_make_api_call(self, operation_name, kwarg) + + +class Test_RolesAnywhere_Service: + @mock_aws + def test_service(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert rolesanywhere.service == "rolesanywhere" + + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_trust_anchors(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert len(rolesanywhere.trust_anchors) == 1 + ta = rolesanywhere.trust_anchors[TA_ARN] + assert isinstance(ta, TrustAnchor) + assert ta.id == TA_ID + assert ta.name == "pqc-trust" + assert ta.enabled is True + assert ta.source_type == "AWS_ACM_PCA" + assert ta.acm_pca_arn == PCA_ARN + assert ta.region == AWS_REGION_US_EAST_1 + assert ta.tags == [{"key": "Environment", "value": "test"}] + + @patch( + "botocore.client.BaseClient._make_api_call", new=mock_make_api_call_tags_failure + ) + @mock_aws + def test_list_trust_anchors_continues_when_tags_fail(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + rolesanywhere = RolesAnywhere(aws_provider) + assert len(rolesanywhere.trust_anchors) == 1 + ta = rolesanywhere.trust_anchors[TA_ARN] + assert isinstance(ta, TrustAnchor) + assert ta.id == TA_ID + assert ta.tags == [] diff --git a/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py b/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py new file mode 100644 index 0000000000..2038fdde57 --- /dev/null +++ b/tests/providers/aws/services/rolesanywhere/rolesanywhere_trust_anchor_pqc_pki/rolesanywhere_trust_anchor_pqc_pki_test.py @@ -0,0 +1,203 @@ +from unittest import mock + +from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority +from prowler.providers.aws.services.rolesanywhere.rolesanywhere_service import ( + TrustAnchor, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +TA_ID = "11111111-2222-3333-4444-555555555555" +TA_NAME = "pqc-trust" +TA_ARN = f"arn:aws:rolesanywhere:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:trust-anchor/{TA_ID}" +PCA_ID = "12345678-1234-1234-1234-123456789012" +PCA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{PCA_ID}" + + +def _trust_anchor(*, source_type: str, acm_pca_arn: str = ""): + return TrustAnchor( + arn=TA_ARN, + id=TA_ID, + name=TA_NAME, + region=AWS_REGION_US_EAST_1, + enabled=True, + source_type=source_type, + acm_pca_arn=acm_pca_arn, + ) + + +def _ca(key_algorithm: str, status: str = "ACTIVE"): + return CertificateAuthority( + arn=PCA_ARN, + id=PCA_ID, + region=AWS_REGION_US_EAST_1, + status=status, + type="SUBORDINATE", + usage_mode="GENERAL_PURPOSE", + key_algorithm=key_algorithm, + signing_algorithm=( + key_algorithm if "ML_DSA" in key_algorithm else "SHA256WITHRSA" + ), + ) + + +def _build_clients(trust_anchors, certificate_authorities=None, audit_config=None): + ra_client = mock.MagicMock() + ra_client.trust_anchors = trust_anchors + ra_client.audit_config = audit_config or {} + pca_client = mock.MagicMock() + pca_client.certificate_authorities = certificate_authorities or {} + return ra_client, pca_client + + +def _patched(ra_client, pca_client): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + return [ + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_client", + new=ra_client, + ), + mock.patch( + "prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki.acmpca_client", + new=pca_client, + ), + ] + + +def _enter(patches): + from contextlib import ExitStack + + stack = ExitStack() + for p in patches: + stack.enter_context(p) + return stack + + +class Test_rolesanywhere_trust_anchor_pqc_pki: + def test_no_trust_anchors(self): + ra_client, pca_client = _build_clients({}) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 0 + + def test_pca_backed_pqc(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "ML_DSA_65" in result[0].status_extended + assert result[0].resource_id == TA_ID + assert result[0].resource_arn == TA_ARN + + def test_pca_backed_custom_allowlist_pqc(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("CUSTOM_PQC")}, + audit_config={"rolesanywhere_pqc_pca_key_algorithms": ["CUSTOM_PQC"]}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "CUSTOM_PQC" in result[0].status_extended + assert result[0].resource_id == TA_ID + assert result[0].resource_arn == TA_ARN + + def test_custom_allowlist_replaces_default(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65")}, + audit_config={"rolesanywhere_pqc_pca_key_algorithms": ["ML_DSA_87"]}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ML_DSA_65" in result[0].status_extended + assert "not post-quantum (ML-DSA)" in result[0].status_extended + + def test_pca_backed_rsa(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("RSA_2048")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RSA_2048" in result[0].status_extended + + def test_pca_backed_inactive_pqc_ca(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={PCA_ARN: _ca("ML_DSA_65", status="DISABLED")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "DISABLED status" in result[0].status_extended + + def test_pca_not_in_inventory(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="AWS_ACM_PCA", acm_pca_arn=PCA_ARN)}, + certificate_authorities={}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "could not be inspected" in result[0].status_extended + + def test_certificate_bundle_source(self): + ra_client, pca_client = _build_clients( + {TA_ARN: _trust_anchor(source_type="CERTIFICATE_BUNDLE")}, + ) + with _enter(_patched(ra_client, pca_client)): + from prowler.providers.aws.services.rolesanywhere.rolesanywhere_trust_anchor_pqc_pki.rolesanywhere_trust_anchor_pqc_pki import ( + rolesanywhere_trust_anchor_pqc_pki, + ) + + result = rolesanywhere_trust_anchor_pqc_pki().execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "CERTIFICATE_BUNDLE" in result[0].status_extended diff --git a/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py new file mode 100644 index 0000000000..32f5487100 --- /dev/null +++ b/tests/providers/aws/services/sagemaker/sagemaker_clarify_exists/sagemaker_clarify_exists_test.py @@ -0,0 +1,247 @@ +from unittest import mock + +from prowler.providers.aws.services.sagemaker.sagemaker_service import ProcessingJob +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-clarify-processing:1.0" +NON_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/sagemaker-xgboost:1.0" +CUSTOM_CLARIFY_IMAGE_URI = f"{AWS_ACCOUNT_NUMBER}.dkr.ecr.{AWS_REGION_US_EAST_1}.amazonaws.com/my-clarify-thing:1.0" +PROCESSING_JOB_ARN = f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/clarify-job" + + +class Test_sagemaker_clarify_exists: + def test_no_processing_jobs_no_scanned_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = set() + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 0 + + def test_no_processing_jobs_region_scanned(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "sagemaker-clarify" + + def test_non_clarify_processing_job(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="xgboost-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/xgboost-job", + region=AWS_REGION_US_EAST_1, + image_uri=NON_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_custom_image_with_clarify_in_name_does_not_match(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="my-clarify-thing-job", + arn=f"arn:aws:sagemaker:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:processing-job/my-clarify-thing-job", + region=AWS_REGION_US_EAST_1, + image_uri=CUSTOM_CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_US_EAST_1}." + ) + + def test_clarify_processing_job_exists(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = {AWS_REGION_US_EAST_1} + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + assert result[0].resource_id == "clarify-job" + assert result[0].resource_arn == PROCESSING_JOB_ARN + + def test_mixed_regions(self): + sagemaker_client = mock.MagicMock + sagemaker_client.sagemaker_processing_jobs = [ + ProcessingJob( + name="clarify-job", + arn=PROCESSING_JOB_ARN, + region=AWS_REGION_US_EAST_1, + image_uri=CLARIFY_IMAGE_URI, + ) + ] + sagemaker_client.processing_jobs_scanned_regions = { + AWS_REGION_US_EAST_1, + AWS_REGION_EU_WEST_1, + } + sagemaker_client.audited_partition = "aws" + sagemaker_client.audited_account = AWS_ACCOUNT_NUMBER + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1, AWS_REGION_EU_WEST_1] + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists.sagemaker_client", + sagemaker_client, + ), + ): + from prowler.providers.aws.services.sagemaker.sagemaker_clarify_exists.sagemaker_clarify_exists import ( + sagemaker_clarify_exists, + ) + + check = sagemaker_clarify_exists() + result = check.execute() + + assert len(result) == 2 + + results_by_region = {r.region: r for r in result} + + us_result = results_by_region[AWS_REGION_US_EAST_1] + assert us_result.status == "PASS" + assert ( + us_result.status_extended + == f"SageMaker Clarify processing job clarify-job exists in region {AWS_REGION_US_EAST_1}." + ) + + eu_result = results_by_region[AWS_REGION_EU_WEST_1] + assert eu_result.status == "FAIL" + assert ( + eu_result.status_extended + == f"No SageMaker Clarify processing jobs found in region {AWS_REGION_EU_WEST_1}." + ) diff --git a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py index d49a506fd9..50431c2e13 100644 --- a/tests/providers/aws/services/sagemaker/sagemaker_service_test.py +++ b/tests/providers/aws/services/sagemaker/sagemaker_service_test.py @@ -396,13 +396,13 @@ class Test_SageMaker_Service: sagemaker_service = SageMaker(audit_info) # Check that __threading_call__ was called for _list_tags_for_resource - # (one for each resource type: models, notebooks, training jobs, endpoint configs, domains) + # (one for each resource type: models, notebooks, training jobs, processing jobs, endpoint configs, domains) tag_calls = [ c for c in mock_threading_call.call_args_list if c[0][0] == sagemaker_service._list_tags_for_resource ] - assert len(tag_calls) == 5 + assert len(tag_calls) == 6 # Test SageMaker list model package groups def test_list_model_package_groups(self): diff --git a/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py new file mode 100644 index 0000000000..01af147e9a --- /dev/null +++ b/tests/providers/aws/services/securityhub/securityhub_delegated_admin_enabled_all_regions/securityhub_delegated_admin_enabled_all_regions_test.py @@ -0,0 +1,512 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_EU_WEST_1, + set_mocked_aws_provider, +) + +orig = botocore.client.BaseClient._make_api_call + +HUB_ARN = f"arn:aws:securityhub:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:hub/default" + + +def _active_hub_responses(operation_name): + """Return a moto-friendly response for hub-describing API calls. + + Returns None if the operation is not one of the hub APIs (so the caller + can fall back to the default behavior). + """ + if operation_name == "DescribeHub": + return { + "HubArn": HUB_ARN, + "SubscribedAt": "2024-01-01T00:00:00.000Z", + "AutoEnableControls": True, + } + if operation_name == "GetEnabledStandards": + return {"StandardsSubscriptions": []} + if operation_name == "ListEnabledProductsForImport": + return {"ProductSubscriptions": []} + if operation_name == "ListTagsForResource": + return {"Tags": {}} + return None + + +def mock_make_api_call_org_admin_and_config(self, operation_name, api_params): + """Mock organization admin accounts and configuration APIs - PASS scenario.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": True, + "AutoEnableStandards": "DEFAULT", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_org_admin_no_auto_enable(self, operation_name, api_params): + """Mock organization admin configured but auto-enable disabled.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + { + "AdminAccountId": "123456789012", + "AdminStatus": "ENABLED", + } + ] + } + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_no_org_admin(self, operation_name, api_params): + """Mock no organization admin configured.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + if operation_name == "DescribeOrganizationConfiguration": + return { + "AutoEnable": False, + "AutoEnableStandards": "NONE", + } + return orig(self, operation_name, api_params) + + +def mock_make_api_call_securityhub_not_subscribed(self, operation_name, api_params): + """Simulate Security Hub not subscribed in the account (InvalidAccessException).""" + if operation_name == "DescribeHub": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "InvalidAccessException", + "Message": "Account is not subscribed to AWS Security Hub", + } + }, + operation_name, + ) + if operation_name == "ListOrganizationAdminAccounts": + return {"AdminAccounts": []} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_access_denied(self, operation_name, api_params): + """Hub is ACTIVE but ListOrganizationAdminAccounts is denied — lookup-failed path.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform: securityhub:ListOrganizationAdminAccounts", + } + }, + operation_name, + ) + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_admin_lookup_unexpected(self, operation_name, api_params): + """ListOrganizationAdminAccounts raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + raise RuntimeError("simulated transient error") + if operation_name == "DescribeOrganizationConfiguration": + return {"AutoEnable": True, "AutoEnableStandards": "DEFAULT"} + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_other_client_error( + self, operation_name, api_params +): + """DescribeOrganizationConfiguration raises a non-access ClientError — else branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise botocore.exceptions.ClientError( + {"Error": {"Code": "InternalServerError", "Message": "boom"}}, + operation_name, + ) + return orig(self, operation_name, api_params) + + +def mock_make_api_call_describe_org_config_unexpected(self, operation_name, api_params): + """DescribeOrganizationConfiguration raises a non-ClientError — bare Exception branch.""" + hub_resp = _active_hub_responses(operation_name) + if hub_resp is not None: + return hub_resp + if operation_name == "ListOrganizationAdminAccounts": + return { + "AdminAccounts": [ + {"AdminAccountId": "123456789012", "AdminStatus": "ENABLED"} + ] + } + if operation_name == "DescribeOrganizationConfiguration": + raise RuntimeError("simulated transient error") + return orig(self, operation_name, api_params) + + +class Test_securityhub_delegated_admin_enabled_all_regions: + def teardown_method(self): + """Evict cached securityhub modules so legacy mock.patch-based tests + in the same session see a fresh import path.""" + import sys + + for mod in ( + "prowler.providers.aws.services.securityhub.securityhub_client", + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions", + ): + sys.modules.pop(mod, None) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_securityhub_not_subscribed, + ) + @mock_aws + def test_no_securityhub(self): + """Test when Security Hub is not subscribed in any region.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + # Should have findings for each region (with NOT_AVAILABLE hubs) + assert len(result) > 0 + # All should fail since hub is not enabled + for finding in result: + assert finding.status == "FAIL" + assert "Security Hub not enabled" in finding.status_extended + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_no_org_admin, + ) + @mock_aws + def test_securityhub_enabled_no_delegated_admin(self): + """Test when Security Hub is enabled but no delegated admin is configured.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "no delegated administrator configured" + in eu_west_1_result.status_extended + ) + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_no_auto_enable, + ) + @mock_aws + def test_securityhub_enabled_with_admin_no_auto_enable(self): + """Test when Security Hub is enabled with delegated admin but auto-enable is off.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "organization auto-enable not configured" + in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_org_admin_and_config, + ) + @mock_aws + def test_securityhub_enabled_with_admin_and_auto_enable(self): + """Test when Security Hub is enabled with delegated admin and auto-enable on (PASS).""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "PASS" + assert "delegated admin configured" in eu_west_1_result.status_extended + assert "auto-enable" in eu_west_1_result.status_extended + assert eu_west_1_result.resource_arn == HUB_ARN + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_access_denied, + ) + @mock_aws + def test_admin_lookup_access_denied(self): + """AccessDenied on ListOrganizationAdminAccounts must FAIL with unknown-admin message.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=SecurityHub(aws_provider), + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + check = securityhub_delegated_admin_enabled_all_regions() + result = check.execute() + + eu_west_1_result = None + for finding in result: + if finding.region == AWS_REGION_EU_WEST_1: + eu_west_1_result = finding + break + + assert eu_west_1_result is not None + assert eu_west_1_result.status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in eu_west_1_result.status_extended + ) + assert ( + "no delegated administrator configured" + not in eu_west_1_result.status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_admin_lookup_unexpected, + ) + @mock_aws + def test_admin_lookup_unexpected_exception(self): + """Non-ClientError raised from ListOrganizationAdminAccounts still sets lookup_failed.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.organization_admin_lookup_failed is True + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "FAIL" + assert ( + "delegated administrator status could not be determined" + in result[0].status_extended + ) + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_other_client_error, + ) + @mock_aws + def test_describe_org_config_other_client_error(self): + """Non-access ClientError on DescribeOrganizationConfiguration is logged at error level.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + # organization_config_available stays False, so the auto-enable issue is suppressed + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + # Admin is configured and hub is active; with org config unavailable the + # check should PASS because there are no other detectable issues. + assert result and result[0].status == "PASS" + + @patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_describe_org_config_unexpected, + ) + @mock_aws + def test_describe_org_config_unexpected_exception(self): + """Non-ClientError on DescribeOrganizationConfiguration is caught by bare except.""" + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.securityhub.securityhub_service import ( + SecurityHub, + ) + + service = SecurityHub(aws_provider) + assert service.securityhubs[0].organization_config_available is False + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions.securityhub_client", + new=service, + ), + ): + from prowler.providers.aws.services.securityhub.securityhub_delegated_admin_enabled_all_regions.securityhub_delegated_admin_enabled_all_regions import ( + securityhub_delegated_admin_enabled_all_regions, + ) + + result = securityhub_delegated_admin_enabled_all_regions().execute() + assert result and result[0].status == "PASS" diff --git a/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py b/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py new file mode 100644 index 0000000000..d1a098f0ad --- /dev/null +++ b/tests/providers/aws/services/transfer/transfer_server_pqc_ssh_kex_enabled/transfer_server_pqc_ssh_kex_enabled_test.py @@ -0,0 +1,215 @@ +from unittest import mock +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +SERVER_ID = "s-01234567890abcdef" +SERVER_ARN = ( + f"arn:aws:transfer:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:server/{SERVER_ID}" +) + +make_api_call = botocore.client.BaseClient._make_api_call + + +def _make_describe_server_mock(security_policy_name: str): + def _mock(self, operation_name, kwarg): + if operation_name == "ListServers": + return { + "Servers": [ + { + "Arn": SERVER_ARN, + "ServerId": SERVER_ID, + } + ] + } + if operation_name == "DescribeServer": + return { + "Server": { + "Arn": SERVER_ARN, + "ServerId": SERVER_ID, + "Protocols": ["SFTP"], + "SecurityPolicyName": security_policy_name, + } + } + return make_api_call(self, operation_name, kwarg) + + return _mock + + +mock_pqc = _make_describe_server_mock("TransferSecurityPolicy-2025-03") +mock_fips_pqc = _make_describe_server_mock("TransferSecurityPolicy-FIPS-2025-03") +mock_classical = _make_describe_server_mock("TransferSecurityPolicy-2024-01") +mock_no_policy = _make_describe_server_mock("") + + +class Test_transfer_server_pqc_ssh_kex_enabled: + @mock_aws + def test_no_servers(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 0 + + @patch("botocore.client.BaseClient._make_api_call", new=mock_pqc) + @mock_aws + def test_pq_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "TransferSecurityPolicy-2025-03" in result[0].status_extended + assert result[0].resource_id == SERVER_ID + assert result[0].resource_arn == SERVER_ARN + assert result[0].region == AWS_REGION_US_EAST_1 + + @patch("botocore.client.BaseClient._make_api_call", new=mock_fips_pqc) + @mock_aws + def test_fips_pq_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "FIPS-2025-03" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_classical) + @mock_aws + def test_classical_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "TransferSecurityPolicy-2024-01" in result[0].status_extended + assert "does not enable post-quantum" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_no_policy) + @mock_aws + def test_missing_policy(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "" in result[0].status_extended + + @patch("botocore.client.BaseClient._make_api_call", new=mock_classical) + @mock_aws + def test_configurable_allowlist(self): + from prowler.providers.aws.services.transfer.transfer_service import Transfer + + aws_provider = set_mocked_aws_provider( + [AWS_REGION_US_EAST_1], + audit_config={ + "transfer_pqc_ssh_allowed_policies": [ + "TransferSecurityPolicy-2025-03", + "TransferSecurityPolicy-2024-01", + ] + }, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled.transfer_client", + new=Transfer(aws_provider), + ): + from prowler.providers.aws.services.transfer.transfer_server_pqc_ssh_kex_enabled.transfer_server_pqc_ssh_kex_enabled import ( + transfer_server_pqc_ssh_kex_enabled, + ) + + check = transfer_server_pqc_ssh_kex_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/transfer/transfer_service_test.py b/tests/providers/aws/services/transfer/transfer_service_test.py index 61a963ab09..7a08e66c92 100644 --- a/tests/providers/aws/services/transfer/transfer_service_test.py +++ b/tests/providers/aws/services/transfer/transfer_service_test.py @@ -35,6 +35,7 @@ def mock_make_api_call(self, operation_name, kwarg): "Arn": SERVER_ARN, "ServerId": SERVER_ID, "Protocols": ["SFTP"], + "SecurityPolicyName": "TransferSecurityPolicy-2025-03", "Tags": [{"Key": "key", "Value": "value"}], } } @@ -83,3 +84,7 @@ class Test_transfer_service: assert transfer.servers[SERVER_ARN].region == "us-east-1" assert transfer.servers[SERVER_ARN].tags == [{"Key": "key", "Value": "value"}] assert transfer.servers[SERVER_ARN].protocols[0] == Protocol.SFTP + assert ( + transfer.servers[SERVER_ARN].security_policy_name + == "TransferSecurityPolicy-2025-03" + ) diff --git a/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py new file mode 100644 index 0000000000..b7c2ac3bd9 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_auto_upgrade_enabled/aks_cluster_auto_upgrade_enabled_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +def build_cluster(auto_upgrade_channel): + return Cluster( + id="/sub/rg/cluster1", + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + auto_upgrade_channel=auto_upgrade_channel, + ) + + +class Test_aks_cluster_auto_upgrade_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + aks_client.clusters = {} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + cluster = build_cluster(auto_upgrade_channel="stable") + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + @pytest.mark.parametrize("auto_upgrade_channel", [None, "", "none", "None"]) + def test_fail(self, auto_upgrade_channel): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled.aks_client", + new=aks_client, + ), + ): + from prowler.providers.azure.services.aks.aks_cluster_auto_upgrade_enabled.aks_cluster_auto_upgrade_enabled import ( + aks_cluster_auto_upgrade_enabled, + ) + + cluster = build_cluster(auto_upgrade_channel=auto_upgrade_channel) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_auto_upgrade_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py new file mode 100644 index 0000000000..a209a50199 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_azure_monitor_enabled/aks_cluster_azure_monitor_enabled_test.py @@ -0,0 +1,121 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_azure_monitor_enabled." + "aks_cluster_azure_monitor_enabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_azure_monitor_enabled + + +def build_cluster(azure_monitor_enabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + azure_monitor_enabled=azure_monitor_enabled, + ) + + +class Test_aks_cluster_azure_monitor_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + cluster = build_cluster(azure_monitor_enabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has Azure Monitor managed Prometheus metrics enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("azure_monitor_enabled", [False, None]) + def test_fail(self, azure_monitor_enabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_azure_monitor_enabled = get_check_class() + + cluster = build_cluster(azure_monitor_enabled=azure_monitor_enabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_azure_monitor_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' does not have Azure Monitor managed Prometheus metrics enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py new file mode 100644 index 0000000000..0575b67a4a --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_defender_enabled/aks_cluster_defender_enabled_test.py @@ -0,0 +1,122 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_defender_enabled." + "aks_cluster_defender_enabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_defender_enabled + + +def build_cluster(defender_enabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + defender_enabled=defender_enabled, + ) + + +class Test_aks_cluster_defender_enabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has Defender for Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("defender_enabled", [False, None, "true"]) + def test_fail(self, defender_enabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_defender_enabled = get_check_class() + + cluster = build_cluster(defender_enabled=defender_enabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_defender_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' does not have Defender for " + "Containers enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py b/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py new file mode 100644 index 0000000000..a4c63dc172 --- /dev/null +++ b/tests/providers/azure/services/aks/aks_cluster_local_accounts_disabled/aks_cluster_local_accounts_disabled_test.py @@ -0,0 +1,121 @@ +from importlib import import_module +from unittest import mock +from uuid import uuid4 + +import pytest + +from prowler.providers.azure.services.aks.aks_service import Cluster +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +CHECK_MODULE = ( + "prowler.providers.azure.services.aks.aks_cluster_local_accounts_disabled." + "aks_cluster_local_accounts_disabled" +) +CHECK_CLIENT_PATCH = f"{CHECK_MODULE}.aks_client" + + +def get_check_class(): + return import_module(CHECK_MODULE).aks_cluster_local_accounts_disabled + + +def build_cluster(local_accounts_disabled): + return Cluster( + id=str(uuid4()), + name="test-cluster", + public_fqdn="test.azmk8s.io", + private_fqdn=None, + network_policy=None, + agent_pool_profiles=[], + rbac_enabled=True, + location="eastus", + local_accounts_disabled=local_accounts_disabled, + ) + + +class Test_aks_cluster_local_accounts_disabled: + def test_no_subscriptions(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + aks_client.clusters = {} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + cluster = build_cluster(local_accounts_disabled=True) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has local accounts disabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + @pytest.mark.parametrize("local_accounts_disabled", [False, None]) + def test_fail(self, local_accounts_disabled): + aks_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + CHECK_CLIENT_PATCH, + new=aks_client, + ), + ): + aks_cluster_local_accounts_disabled = get_check_class() + + cluster = build_cluster(local_accounts_disabled=local_accounts_disabled) + aks_client.clusters = {AZURE_SUBSCRIPTION_ID: {cluster.id: cluster}} + + check = aks_cluster_local_accounts_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cluster 'test-cluster' has local accounts enabled." + ) + assert result[0].resource_name == "test-cluster" + assert result[0].resource_id == cluster.id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py new file mode 100644 index 0000000000..8b4330d64c --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_automatic_failover_enabled/cosmosdb_account_automatic_failover_enabled_test.py @@ -0,0 +1,115 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_automatic_failover_enabled: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + enable_automatic_failover=True, + ) + ] + } + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_automatic_failover_enabled.cosmosdb_account_automatic_failover_enabled import ( + cosmosdb_account_automatic_failover_enabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + enable_automatic_failover=False, + ) + ] + } + + check = cosmosdb_account_automatic_failover_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py new file mode 100644 index 0000000000..0cc282485b --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_backup_policy_continuous/cosmosdb_account_backup_policy_continuous_test.py @@ -0,0 +1,157 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_backup_policy_continuous: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 0 + + def test_pass(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type="Continuous", + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_periodic(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type="Periodic", + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_fail_no_backup_policy(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_backup_policy_continuous.cosmosdb_account_backup_policy_continuous import ( + cosmosdb_account_backup_policy_continuous, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + backup_policy_type=None, + ) + ] + } + + check = cosmosdb_account_backup_policy_continuous() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py new file mode 100644 index 0000000000..b14c08f697 --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_minimum_tls_version/cosmosdb_account_minimum_tls_version_test.py @@ -0,0 +1,209 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_minimum_tls_version: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 0 + + def test_pass_tls12(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls12", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} enforces TLS 1.2 or higher." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_pass_tls13(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls13", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_tls11(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version="Tls11", + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} does not enforce TLS 1.2 or higher." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_fail_no_tls_version(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_minimum_tls_version.cosmosdb_account_minimum_tls_version import ( + cosmosdb_account_minimum_tls_version, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + minimal_tls_version=None, + ) + ] + } + + check = cosmosdb_account_minimum_tls_version() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py b/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py new file mode 100644 index 0000000000..60297d6138 --- /dev/null +++ b/tests/providers/azure/services/cosmosdb/cosmosdb_account_public_network_access_disabled/cosmosdb_account_public_network_access_disabled_test.py @@ -0,0 +1,210 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_cosmosdb_account_public_network_access_disabled: + def test_no_subscriptions(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + + cosmosdb_client.accounts = {} + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 0 + + def test_pass_disabled(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="Disabled", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} has public network access disabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_pass_secured_by_perimeter(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="SecuredByPerimeter", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_fail_enabled(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access="Enabled", + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"CosmosDB account test-account from subscription " + f"{AZURE_SUBSCRIPTION_ID} does not have public network access " + f"disabled (current value: 'Enabled')." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_fail_no_public_network_access(self): + cosmosdb_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled.cosmosdb_client", + new=cosmosdb_client, + ), + ): + from prowler.providers.azure.services.cosmosdb.cosmosdb_account_public_network_access_disabled.cosmosdb_account_public_network_access_disabled import ( + cosmosdb_account_public_network_access_disabled, + ) + from prowler.providers.azure.services.cosmosdb.cosmosdb_service import ( + Account, + ) + + cosmosdb_client.accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id="/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.DocumentDB/databaseAccounts/test-account", + name="test-account", + kind="GlobalDocumentDB", + type="Microsoft.DocumentDB/databaseAccounts", + tags={}, + is_virtual_network_filter_enabled=False, + location="eastus", + private_endpoint_connections=[], + disable_local_auth=False, + public_network_access=None, + ) + ] + } + + check = cosmosdb_account_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/databricks/databricks_service_test.py b/tests/providers/azure/services/databricks/databricks_service_test.py index 69651f2235..f663d81fe2 100644 --- a/tests/providers/azure/services/databricks/databricks_service_test.py +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -18,6 +18,8 @@ def mock_databricks_get_workspaces(_): id="test-workspace-id", name="test-workspace", location="eastus", + public_network_access="Disabled", + no_public_ip_enabled=True, custom_managed_vnet_id="test-vnet-id", managed_disk_encryption=ManagedDiskEncryption( key_name="test-key", @@ -53,6 +55,8 @@ class Test_Databricks_Service: assert workspace.id == "test-workspace-id" assert workspace.name == "test-workspace" assert workspace.location == "eastus" + assert workspace.public_network_access == "Disabled" + assert workspace.no_public_ip_enabled is True assert workspace.custom_managed_vnet_id == "test-vnet-id" assert ( workspace.managed_disk_encryption.__class__.__name__ diff --git a/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py new file mode 100644 index 0000000000..a7903770c6 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_no_public_ip_enabled/databricks_workspace_no_public_ip_enabled_test.py @@ -0,0 +1,174 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_no_public_ip_enabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_no_public_ip_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=False, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=True, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has secure cluster connectivity (no public IP) enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_no_public_ip_not_reported(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + no_public_ip_enabled=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_no_public_ip_enabled.databricks_workspace_no_public_ip_enabled import ( + databricks_workspace_no_public_ip_enabled, + ) + + check = databricks_workspace_no_public_ip_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} does not expose secure cluster connectivity (no public IP) settings (for example, serverless workspaces have no public-IP cluster nodes); verify the network configuration manually." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py new file mode 100644 index 0000000000..7d39d38760 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_public_network_access_disabled/databricks_workspace_public_network_access_disabled_test.py @@ -0,0 +1,170 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_public_network_access_disabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_public_network_access_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_public_network_access_not_set(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + + def test_databricks_workspace_public_network_access_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + public_network_access="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_public_network_access_disabled.databricks_workspace_public_network_access_disabled import ( + databricks_workspace_public_network_access_disabled, + ) + + check = databricks_workspace_public_network_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access disabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py new file mode 100644 index 0000000000..b81d10d31d --- /dev/null +++ b/tests/providers/azure/services/defender/defender_ensure_defender_cspm_is_on/defender_ensure_defender_cspm_is_on_test.py @@ -0,0 +1,117 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.defender.defender_service import Pricing +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_defender_ensure_defender_cspm_is_on: + def test_defender_no_cspm(self): + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 0 + + def test_defender_cspm_pricing_tier_not_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Free", + free_trial_remaining_time=0, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to OFF (pricing tier not standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id + + def test_defender_cspm_pricing_tier_standard(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + defender_client.pricings = { + AZURE_SUBSCRIPTION_ID: { + "CloudPosture": Pricing( + resource_id=resource_id, + resource_name="Defender plan CSPM", + pricing_tier="Standard", + free_trial_remaining_time=0, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_defender_cspm_is_on.defender_ensure_defender_cspm_is_on import ( + defender_ensure_defender_cspm_is_on, + ) + + check = defender_ensure_defender_cspm_is_on() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Defender plan CSPM from subscription {AZURE_SUBSCRIPTION_DISPLAY} is set to ON (pricing tier standard)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Defender plan CSPM" + assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py b/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py new file mode 100644 index 0000000000..89fa5bac16 --- /dev/null +++ b/tests/providers/azure/services/entra/entra_app_registration_credential_not_expired/entra_app_registration_credential_not_expired_test.py @@ -0,0 +1,269 @@ +from datetime import datetime, timezone, timedelta +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + + +class Test_entra_app_registration_credential_not_expired: + def test_entra_no_tenants(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_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + + entra_client.app_registrations = {} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 0 + + def test_entra_app_no_credentials(self): + entra_client = mock.MagicMock + app_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_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppRegistration, + ) + + app = AppRegistration(id=app_id, name="no-creds-app", credentials=[]) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 0 + + def test_entra_app_credential_expired(self): + entra_client = mock.MagicMock + app_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_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="expired-app", + credentials=[ + AppCredential( + display_name="old-secret", + credential_type="password", + end_date_time=datetime.now(timezone.utc) - timedelta(days=30), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "expired" in result[0].status_extended + + def test_entra_app_credential_expiring_soon(self): + entra_client = mock.MagicMock + app_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_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="expiring-soon-app", + credentials=[ + AppCredential( + display_name="expiring-cert", + credential_type="certificate", + end_date_time=datetime.now(timezone.utc) + timedelta(days=15), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "expiring in" in result[0].status_extended + + def test_entra_app_credential_no_expiration(self): + entra_client = mock.MagicMock + app_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_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="no-expiry-app", + credentials=[ + AppCredential( + display_name="forever-secret", + credential_type="password", + end_date_time=None, + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration" in result[0].status_extended + + def test_entra_app_credential_valid(self): + entra_client = mock.MagicMock + app_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_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="healthy-app", + credentials=[ + AppCredential( + display_name="good-secret", + credential_type="password", + end_date_time=datetime.now(timezone.utc) + timedelta(days=180), + ) + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "more days" in result[0].status_extended + + def test_entra_app_multiple_credentials_mixed(self): + entra_client = mock.MagicMock + app_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_app_registration_credential_not_expired.entra_app_registration_credential_not_expired.entra_client", + new=entra_client, + ), + ): + from prowler.providers.azure.services.entra.entra_app_registration_credential_not_expired.entra_app_registration_credential_not_expired import ( + entra_app_registration_credential_not_expired, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AppCredential, + AppRegistration, + ) + + app = AppRegistration( + id=app_id, + name="mixed-app", + credentials=[ + AppCredential( + display_name="expired-one", + credential_type="password", + end_date_time=datetime.now(timezone.utc) - timedelta(days=10), + ), + AppCredential( + display_name="valid-one", + credential_type="certificate", + end_date_time=datetime.now(timezone.utc) + timedelta(days=200), + ), + ], + ) + entra_client.app_registrations = {DOMAIN: {app_id: app}} + + check = entra_app_registration_credential_not_expired() + result = check.execute() + assert len(result) == 2 + statuses = {r.status for r in result} + assert "FAIL" in statuses + assert "PASS" in statuses diff --git a/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py b/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py new file mode 100644 index 0000000000..e3102bf3e9 --- /dev/null +++ b/tests/providers/azure/services/entra/entra_authentication_methods_policy_strong_auth_enforced/entra_authentication_methods_policy_strong_auth_enforced_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import DOMAIN, set_mocked_azure_provider + +CHECK_MODULE = ( + "prowler.providers.azure.services.entra." + "entra_authentication_methods_policy_strong_auth_enforced." + "entra_authentication_methods_policy_strong_auth_enforced" +) + + +class Test_entra_authentication_methods_policy_strong_auth_enforced: + def test_entra_no_tenants(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + + entra_client.authentication_methods_policy = {} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 0 + + def test_entra_policy_none(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + + entra_client.authentication_methods_policy = {DOMAIN: None} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 0 + + def test_entra_registration_enabled_strong_method_enabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="enabled", + method_configurations=[ + AuthMethodConfig( + id="MicrosoftAuthenticator", + method_name="microsoftAuthenticator", + state="enabled", + ), + AuthMethodConfig(id="Sms", method_name="sms", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Authentication Methods Policy" + assert result[0].resource_id == "authMethodsPolicy" + assert "is enforced" in result[0].status_extended + assert "microsoftAuthenticator" in result[0].status_extended + + def test_entra_registration_disabled_no_strong_methods(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="disabled", + method_configurations=[ + AuthMethodConfig(id="Sms", method_name="sms", state="enabled"), + AuthMethodConfig(id="Voice", method_name="voice", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "is not enforced" in result[0].status_extended + assert "registration campaign is not enabled" in result[0].status_extended + assert "no strong authentication methods" in result[0].status_extended + + def test_entra_registration_disabled_strong_method_enabled(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="disabled", + method_configurations=[ + AuthMethodConfig(id="Fido2", method_name="fido2", state="enabled"), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + # Strong method present but registration campaign off -> not enforced + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "registration campaign is not enabled" in result[0].status_extended + assert "no strong authentication methods" not in result[0].status_extended + + def test_entra_multiple_strong_methods(self): + entra_client = mock.MagicMock + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_MODULE}.entra_client", new=entra_client), + ): + from prowler.providers.azure.services.entra.entra_authentication_methods_policy_strong_auth_enforced.entra_authentication_methods_policy_strong_auth_enforced import ( + entra_authentication_methods_policy_strong_auth_enforced, + ) + from prowler.providers.azure.services.entra.entra_service import ( + AuthMethodConfig, + AuthMethodsPolicy, + ) + + policy = AuthMethodsPolicy( + id="authMethodsPolicy", + registration_enforcement_state="enabled", + method_configurations=[ + AuthMethodConfig( + id="MicrosoftAuthenticator", + method_name="microsoftAuthenticator", + state="enabled", + ), + AuthMethodConfig(id="Fido2", method_name="fido2", state="enabled"), + AuthMethodConfig( + id="x509", method_name="x509Certificate", state="enabled" + ), + ], + ) + entra_client.authentication_methods_policy = {DOMAIN: policy} + + check = entra_authentication_methods_policy_strong_auth_enforced() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "microsoftAuthenticator" in result[0].status_extended + assert "fido2" in result[0].status_extended diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..c5e1f27de2 --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,125 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_geo_redundant_backup_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Geo-redundant backup is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Geo-redundant backup is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py new file mode 100644 index 0000000000..c1084ec47b --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_high_availability_enabled/mysql_flexible_server_high_availability_enabled_test.py @@ -0,0 +1,166 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_high_availability_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_high_availability_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"High availability is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_mysql_high_availability_not_set(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"High availability is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + + def test_mysql_high_availability_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + high_availability_mode="ZoneRedundant", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_high_availability_enabled.mysql_flexible_server_high_availability_enabled import ( + mysql_flexible_server_high_availability_enabled, + ) + + check = mysql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"High availability is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py b/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py new file mode 100644 index 0000000000..4166c6f968 --- /dev/null +++ b/tests/providers/azure/services/network/network_subnet_nsg_associated/network_subnet_nsg_associated_test.py @@ -0,0 +1,188 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VNET_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/test-vnet" +SUBNET_ID = f"{VNET_ID}/subnets/test-subnet" +NSG_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/networkSecurityGroups/test-nsg" + + +class Test_network_subnet_nsg_associated: + def test_no_subscriptions(self): + network_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.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + network_client.virtual_networks = {} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 0 + + def test_subnet_with_nsg(self): + network_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.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[VNetSubnet(id=SUBNET_ID, name="test-subnet", nsg_id=NSG_ID)], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_subnet_without_nsg(self): + network_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.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[VNetSubnet(id=SUBNET_ID, name="app-subnet", nsg_id=None)], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_gateway_subnet_excluded(self): + network_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.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[ + VNetSubnet( + id=f"{VNET_ID}/subnets/GatewaySubnet", + name="GatewaySubnet", + nsg_id=None, + ) + ], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + # GatewaySubnet should be excluded + assert len(result) == 0 + + def test_mixed_subnets(self): + network_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.network.network_subnet_nsg_associated.network_subnet_nsg_associated.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + VNetSubnet, + ) + from prowler.providers.azure.services.network.network_subnet_nsg_associated.network_subnet_nsg_associated import ( + network_subnet_nsg_associated, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + subnets=[ + VNetSubnet(id=f"{VNET_ID}/subnets/app", name="app", nsg_id=NSG_ID), + VNetSubnet(id=f"{VNET_ID}/subnets/db", name="db", nsg_id=None), + VNetSubnet( + id=f"{VNET_ID}/subnets/GatewaySubnet", + name="GatewaySubnet", + nsg_id=None, + ), + ], + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_subnet_nsg_associated() + result = check.execute() + # 2 results: app (PASS) + db (FAIL), GatewaySubnet excluded + assert len(result) == 2 + statuses = {r.status for r in result} + assert "PASS" in statuses + assert "FAIL" in statuses diff --git a/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py b/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py new file mode 100644 index 0000000000..f37d3eb781 --- /dev/null +++ b/tests/providers/azure/services/network/network_vnet_ddos_protection_enabled/network_vnet_ddos_protection_enabled_test.py @@ -0,0 +1,99 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VNET_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.Network/virtualNetworks/test-vnet" + + +class Test_network_vnet_ddos_protection_enabled: + def test_no_subscriptions(self): + network_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.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + + network_client.virtual_networks = {} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_ddos_enabled(self): + network_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.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + enable_ddos_protection=True, + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_ddos_disabled(self): + network_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.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled.network_client", + new=network_client, + ), + ): + from prowler.providers.azure.services.network.network_vnet_ddos_protection_enabled.network_vnet_ddos_protection_enabled import ( + network_vnet_ddos_protection_enabled, + ) + from prowler.providers.azure.services.network.network_service import ( + VirtualNetwork, + ) + + vnet = VirtualNetwork( + id=VNET_ID, + name="test-vnet", + location="eastus", + enable_ddos_protection=False, + ) + network_client.virtual_networks = {AZURE_SUBSCRIPTION_ID: [vnet]} + + check = network_vnet_ddos_protection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..db0291c989 --- /dev/null +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,132 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.postgresql.postgresql_service import Server +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +def _make_server(server_id, server_name, geo_redundant_backup): + return Server( + id=server_id, + name=server_name, + resource_group="resource_group", + location="eastus", + require_secure_transport="ON", + active_directory_auth="Enabled", + entra_id_admins=[], + log_checkpoints="ON", + log_connections="ON", + log_disconnections="ON", + connection_throttling="ON", + log_retention_days="3", + firewall=[], + geo_redundant_backup=geo_redundant_backup, + ) + + +class Test_postgresql_flexible_server_geo_redundant_backup_enabled: + def test_no_postgresql_flexible_servers(self): + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_postgresql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Disabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have geo-redundant backup enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_postgresql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Enabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has geo-redundant backup enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py new file mode 100644 index 0000000000..190c8629ce --- /dev/null +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_high_availability_enabled/postgresql_flexible_server_high_availability_enabled_test.py @@ -0,0 +1,168 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.postgresql.postgresql_service import Server +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +def _make_server(server_id, server_name, high_availability_mode): + return Server( + id=server_id, + name=server_name, + resource_group="resource_group", + location="eastus", + require_secure_transport="ON", + active_directory_auth="Enabled", + entra_id_admins=[], + log_checkpoints="ON", + log_connections="ON", + log_disconnections="ON", + connection_throttling="ON", + log_retention_days="3", + firewall=[], + high_availability_mode=high_availability_mode, + ) + + +class Test_postgresql_flexible_server_high_availability_enabled: + def test_no_postgresql_flexible_servers(self): + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_postgresql_high_availability_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Disabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have high availability enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_postgresql_high_availability_not_set(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, None)] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have high availability enabled." + ) + + def test_postgresql_high_availability_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [ + _make_server(server_id, server_name, "ZoneRedundant") + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_high_availability_enabled.postgresql_flexible_server_high_availability_enabled import ( + postgresql_flexible_server_high_availability_enabled, + ) + + check = postgresql_flexible_server_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has high availability enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/postgresql/postgresql_service_test.py b/tests/providers/azure/services/postgresql/postgresql_service_test.py index f36b5675ec..c0b8bca6c4 100644 --- a/tests/providers/azure/services/postgresql/postgresql_service_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.postgresql.postgresql_service import ( EntraIdAdmin, @@ -115,6 +115,44 @@ class Test_SqlServer_Service: == "ON" ) + def test_get_connection_throttling_missing_parameter_returns_none(self): + # PostgreSQL v18 removed the "connection_throttle.enable" parameter; the + # service must degrade gracefully (quiet None) instead of raising and + # aborting the whole subscription's server inventory. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.configurations.get.side_effect = Exception( + "The configuration 'connection_throttle.enable' does not exist for " + "server version 18." + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_connection_throttling( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result is None + mock_logger.error.assert_not_called() + + def test_get_connection_throttling_unexpected_error_logs_error(self): + # Any other failure (permissions, throttling, transient API errors) must + # still be logged as an error, while keeping the scan resilient (None). + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.configurations.get.side_effect = Exception( + "Some unexpected failure" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_connection_throttling( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result is None + mock_logger.error.assert_called_once() + def test_get_log_retention_days(self): postgesql = PostgreSQL(set_mocked_azure_provider()) assert ( @@ -138,6 +176,45 @@ class Test_SqlServer_Service: assert admins[0].principal_name == "Test Admin User" assert admins[0].object_id == "11111111-1111-1111-1111-111111111111" + def test_get_entra_id_admins_aad_not_enabled_logs_warning(self): + # A server using PostgreSQL authentication only (Entra/Azure AD auth + # disabled) is an expected state; it should be logged as a warning, not + # an error, and return an empty admin list. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.administrators.list_by_server.side_effect = Exception( + "Azure AD authentication is not enabled for the given server" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_entra_id_admins( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result == [] + mock_logger.warning.assert_called_once() + mock_logger.error.assert_not_called() + + def test_get_entra_id_admins_unexpected_error_logs_error(self): + # Any other failure (permissions, throttling, transient API errors) is a + # genuine problem and must still be logged as an error. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.administrators.list_by_server.side_effect = Exception( + "Some unexpected failure" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_entra_id_admins( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result == [] + mock_logger.error.assert_called_once() + mock_logger.warning.assert_not_called() + def test_get_firewall(self): postgesql = PostgreSQL(set_mocked_azure_provider()) assert ( diff --git a/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py b/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py new file mode 100644 index 0000000000..56c7e3f5da --- /dev/null +++ b/tests/providers/azure/services/recovery/recovery_vault_backup_policy_retention_adequate/recovery_vault_backup_policy_retention_adequate_test.py @@ -0,0 +1,191 @@ +from unittest import mock +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VAULT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.RecoveryServices/vaults/test-vault" +POLICY_ID = f"{VAULT_ID}/backupPolicies/DefaultPolicy" + + +class Test_recovery_vault_backup_policy_retention_adequate: + def test_no_subscriptions(self): + 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.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + + recovery_client.vaults = {} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 0 + + def test_vault_no_policies(self): + 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.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={}, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "has no backup policies configured" in result[0].status_extended + + def test_policy_adequate_retention(self): + 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.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="DefaultPolicy", + retention_days=90, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "90-day" in result[0].status_extended + + def test_policy_insufficient_retention(self): + 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.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="ShortPolicy", + retention_days=7, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "7-day" in result[0].status_extended + assert "minimum: 30" in result[0].status_extended + + def test_policy_no_retention_configured(self): + 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.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_backup_policy_retention_adequate.recovery_vault_backup_policy_retention_adequate import ( + recovery_vault_backup_policy_retention_adequate, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupPolicy, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_policies={ + POLICY_ID: BackupPolicy( + id=POLICY_ID, + name="NoRetentionPolicy", + retention_days=None, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_backup_policy_retention_adequate() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no daily retention" in result[0].status_extended diff --git a/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py b/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py new file mode 100644 index 0000000000..c9f8aa27b4 --- /dev/null +++ b/tests/providers/azure/services/recovery/recovery_vault_has_protected_items/recovery_vault_has_protected_items_test.py @@ -0,0 +1,116 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + +VAULT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg1/providers/Microsoft.RecoveryServices/vaults/test-vault" + + +class Test_recovery_vault_has_protected_items: + def test_no_subscriptions(self): + 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.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + + recovery_client.vaults = {} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 0 + + def test_vault_with_protected_items(self): + 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.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="test-vault", + location="eastus", + backup_protected_items={ + "item1": BackupItem( + id="item1", + name="vm-backup", + workload_type=None, + ) + }, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "1 protected items" in result[0].status_extended + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == VAULT_ID + assert result[0].resource_name == "test-vault" + assert result[0].location == "eastus" + + def test_vault_empty(self): + 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.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.recovery.recovery_vault_has_protected_items.recovery_vault_has_protected_items import ( + recovery_vault_has_protected_items, + ) + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupVault, + ) + + vault = BackupVault( + id=VAULT_ID, + name="empty-vault", + location="westeurope", + backup_protected_items={}, + ) + recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {VAULT_ID: vault}} + + check = recovery_vault_has_protected_items() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no protected items" in result[0].status_extended + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == VAULT_ID + assert result[0].resource_name == "empty-vault" + assert result[0].location == "westeurope" diff --git a/tests/providers/external/test_dynamic_provider_loading.py b/tests/providers/external/test_dynamic_provider_loading.py index eefde19545..78e308597d 100644 --- a/tests/providers/external/test_dynamic_provider_loading.py +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -417,17 +417,19 @@ class TestIsBuiltinProvider: class TestInitProvidersParserBuiltinDependencyFailure: - """Tests the critical behavior fix: when a built-in provider's arguments - module exists but its imports fail (e.g. boto3 not installed), we must - fail loudly with a clear message — not silently fall through to entry - points as if the provider were external.""" + """Selective fail-loud: init captures failures silently, enforce emits + warning for non-invoked and exits for the invoked broken provider.""" + @patch("sys.argv", ["prowler", "aws"]) @patch("prowler.providers.common.arguments.Provider.is_builtin") @patch("prowler.providers.common.arguments.import_module") def test_builtin_with_missing_transitive_dep_fails_loudly( self, mock_import, mock_is_builtin ): - from prowler.providers.common.arguments import init_providers_parser + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) mock_is_builtin.return_value = True mock_import.side_effect = ImportError("No module named 'boto3'") @@ -435,14 +437,14 @@ class TestInitProvidersParserBuiltinDependencyFailure: parser = MagicMock() parser._providers = ["aws"] - with ( - patch( - "prowler.providers.common.arguments.Provider.get_available_providers", - return_value=["aws"], - ), - pytest.raises(SystemExit), + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws"], ): init_providers_parser(parser) + assert "aws" in parser._builtin_load_failures + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) @patch("prowler.providers.common.arguments.Provider.is_builtin") @patch("prowler.providers.common.arguments.Provider._load_ep_provider") @@ -466,6 +468,290 @@ class TestInitProvidersParserBuiltinDependencyFailure: ext_cls.init_parser.assert_called_once_with(parser) + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_unrelated_builtin_failure_does_not_abort_when_other_provider_invoked( + self, mock_import, mock_is_builtin + ): + """Broken stackit + invoked aws → warning, no abort.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise ImportError("No module named 'stackit.objectstorage'") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + assert "stackit" in parser._builtin_load_failures + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + @patch("sys.argv", ["prowler", "-h"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_no_provider_invoked_failure_does_not_abort( + self, mock_import, mock_is_builtin + ): + """`prowler -h` + broken built-in → warning, help still renders.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'stackit.objectstorage'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "microsoft365"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_microsoft365_alias_still_triggers_fail_loud( + self, mock_import, mock_is_builtin + ): + """Alias `microsoft365 → m365` must be normalised before matching.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'msgraph'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["m365"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "oci"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_oci_alias_still_triggers_fail_loud( + self, mock_import, mock_is_builtin + ): + """Alias `oci → oraclecloud` must be normalised before matching.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = ImportError("No module named 'oci'") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["oraclecloud"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "--output-directory", "stackit"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_flag_value_matching_provider_name_not_treated_as_invoked( + self, mock_import, mock_is_builtin + ): + """Flag-first invocation → invoked is 'aws' (default), not the flag's value.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise ImportError("No module named 'stackit.objectstorage'") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_invoked_builtin_non_import_error_fails_loudly( + self, mock_import, mock_is_builtin + ): + """Non-ImportError in invoked provider → still fail-loud.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + mock_import.side_effect = RuntimeError("Unexpected error in aws init_parser") + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws"], + ): + init_providers_parser(parser) + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + @patch("sys.argv", ["prowler", "aws"]) + @patch("prowler.providers.common.arguments.Provider.is_builtin") + @patch("prowler.providers.common.arguments.import_module") + def test_unrelated_builtin_non_import_error_does_not_abort( + self, mock_import, mock_is_builtin + ): + """Non-ImportError in unrelated provider → warning, no abort.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + mock_is_builtin.return_value = True + aws_module = MagicMock() + + def import_side_effect(module_path): + if "stackit" in module_path: + raise RuntimeError("Unexpected error in stackit init_parser") + return aws_module + + mock_import.side_effect = import_side_effect + + parser = MagicMock() + + with patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ): + init_providers_parser(parser) + enforce_invoked_provider_loaded(parser) + + aws_module.init_parser.assert_called_once_with(parser) + + +class TestParseArgsOverrideAlignment: + """Regression: `parse(args=...)` overrides sys.argv AFTER __init__ ran; + the selective fail-loud must read argv at enforce time, not init time.""" + + def test_enforce_reads_current_sys_argv_not_init_time_sys_argv(self): + """Init with argv=['prowler','-h'] (no provider) captures stackit + failure silently. Enforce with argv=['prowler','stackit'] must + fail-loud — proving alignment under parse(args=...).""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + def import_side_effect(path): + if "stackit" in path: + raise ImportError("No module named 'stackit.objectstorage'") + return MagicMock() + + parser = MagicMock() + + with ( + patch( + "prowler.providers.common.arguments.Provider.is_builtin", + return_value=True, + ), + patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ), + patch( + "prowler.providers.common.arguments.import_module", + side_effect=import_side_effect, + ), + ): + # Phase 1: __init__ with ambient argv = ['prowler', '-h'] + with patch("sys.argv", ["prowler", "-h"]): + init_providers_parser(parser) + # Failure captured silently — no SystemExit during init + assert "stackit" in parser._builtin_load_failures + + # Phase 2: parse(args=...) overrode sys.argv → stackit invoked + with patch("sys.argv", ["prowler", "stackit"]): + with pytest.raises(SystemExit): + enforce_invoked_provider_loaded(parser) + + def test_enforce_reads_current_sys_argv_for_no_invocation(self): + """Inverse: init's argv invokes stackit, but parse(args=['prowler', + '-h']) overrides. Enforce must NOT fail-loud.""" + from prowler.providers.common.arguments import ( + enforce_invoked_provider_loaded, + init_providers_parser, + ) + + def import_side_effect(path): + if "stackit" in path: + raise ImportError("No module named 'stackit.objectstorage'") + return MagicMock() + + parser = MagicMock() + + with ( + patch( + "prowler.providers.common.arguments.Provider.is_builtin", + return_value=True, + ), + patch( + "prowler.providers.common.arguments.Provider.get_available_providers", + return_value=["aws", "stackit"], + ), + patch( + "prowler.providers.common.arguments.import_module", + side_effect=import_side_effect, + ), + ): + # Phase 1: __init__ with ambient argv pretending stackit invoked + with patch("sys.argv", ["prowler", "stackit"]): + init_providers_parser(parser) + assert "stackit" in parser._builtin_load_failures + + # Phase 2: parse(args=['prowler', '-h']) overrode sys.argv → + # no provider invoked anymore → enforce must NOT exit + with patch("sys.argv", ["prowler", "-h"]): + enforce_invoked_provider_loaded(parser) + class TestInitGlobalProviderBuiltinDependencyFailure: """Same contract as TestInitProvidersParserBuiltinDependencyFailure but @@ -502,18 +788,22 @@ class TestInitGlobalProviderBuiltinDependencyFailure: assert cls is None @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") @patch("prowler.providers.common.provider.Provider.get_available_providers") - def test_get_providers_help_text_builtin_path(self, mock_providers, mock_import): + def test_get_providers_help_text_builtin_path( + self, mock_providers, mock_is_builtin, mock_import + ): """get_providers_help_text reads _cli_help_text from a built-in provider module.""" import types mock_providers.return_value = ["fakebuiltin"] + mock_is_builtin.return_value = True mock_cls = type( - "FakeBuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"} + "FakebuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"} ) mock_module = types.ModuleType("fake_module") - mock_module.FakeBuiltinProvider = mock_cls + mock_module.FakebuiltinProvider = mock_cls mock_import.return_value = mock_module help_text = Provider.get_providers_help_text() @@ -2137,3 +2427,340 @@ class TestComplianceTableDispatch: mock_generic.assert_called_once() Provider._global = None + + +# =========================================================================== +# 12. Provider.get_class — Public side-effect-free class resolver +# =========================================================================== + + +class TestGetClass: + """Tests for Provider.get_class(provider) — the public, side-effect-free + class resolver that unblocks the Django API and other callers that need + a provider class without triggering CLI side-effects (sys.exit, global + provider mutation).""" + + # ----------------------------------------------------------------------- + # T1: Built-in provider resolves to correct class + # ----------------------------------------------------------------------- + + def test_get_class_builtin_returns_correct_class(self): + """get_class('aws') returns AwsProvider — identity check.""" + from prowler.providers.aws.aws_provider import AwsProvider + + cls = Provider.get_class("aws") + + assert cls is AwsProvider + + # ----------------------------------------------------------------------- + # T2: External entry-point provider resolves + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_external_ep_returns_class(self, mock_is_builtin, mock_ep): + """get_class resolves an external entry-point provider and returns that class.""" + mock_is_builtin.return_value = False + mock_ep.return_value = [ + _make_entry_point( + "fakeexternal", "pkg:FakeExternalProvider", "prowler.providers" + ), + ] + mock_ep.return_value[0].load.return_value = FakeExternalProvider + + cls = Provider.get_class("fakeexternal") + + assert cls is FakeExternalProvider + + # ----------------------------------------------------------------------- + # T3: Unknown provider raises, does NOT call sys.exit + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_unknown_raises_and_does_not_sys_exit( + self, mock_is_builtin, mock_ep + ): + """get_class raises for an unknown provider and never calls sys.exit.""" + mock_is_builtin.return_value = False + mock_ep.return_value = [] + + # Assert ImportError specifically to enforce the public API contract + # (not a broad Exception). SystemExit belongs in init_global_provider's + # wrapper, not in the pure resolver. + with pytest.raises(ImportError): + Provider.get_class("totally_unknown_xyz_provider") + + # ----------------------------------------------------------------------- + # T4: get_class is PURE for built-ins — no collision warning, no EP call + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.logger") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_builtin_with_ep_shadow_is_pure( + self, mock_is_builtin, mock_import, mock_load_ep, mock_logger + ): + """get_class for a built-in with a same-named EP is PURE: + - returns the built-in class + - does NOT emit a collision warning + - does NOT call _load_ep_provider (so _ep_providers cache stays empty for + this key, proving no side-effect) + """ + import types + + mock_is_builtin.return_value = True + mock_load_ep.return_value = FakeExternalProvider # plug-in shadow present + + fake_module = types.ModuleType("fake_builtin_module") + fake_builtin_cls = type("AwsProvider", (Provider,), {"_type": "aws"}) + fake_module.AwsProvider = fake_builtin_cls + mock_import.return_value = fake_module + + cls = Provider.get_class("aws") + + # Built-in class returned + assert cls is fake_builtin_cls + # No collision warning emitted — that is now init_global_provider's job + warning_msgs = [ + call.args[0] + for call in mock_logger.warning.call_args_list + if call.args and "IGNORED" in call.args[0] + ] + assert not warning_msgs, ( + "get_class must NOT emit a collision warning; " + "init_global_provider owns that responsibility" + ) + # _load_ep_provider must NOT have been called for the built-in path + mock_load_ep.assert_not_called() + # _ep_providers cache must not contain 'aws' (no side-effect) + assert "aws" not in Provider._ep_providers + + # ----------------------------------------------------------------------- + # T4b: Built-in module missing its expected class raises ImportError + # and does NOT fall back to a same-named entry point + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_builtin_missing_class_raises_importerror( + self, mock_is_builtin, mock_import, mock_load_ep + ): + """When is_builtin is True but the module does not define the expected + provider class, get_class raises ImportError and does NOT fall back to a + same-named entry point — falling back would contradict is_builtin and + silently return a foreign class.""" + import types + + mock_is_builtin.return_value = True + # Module imports fine but lacks the expected `Provider` attribute. + empty_module = types.ModuleType("empty_builtin_module") + mock_import.return_value = empty_module + + with pytest.raises(ImportError): + Provider.get_class("aws") + + # Must NOT fall back to entry points for a (broken) built-in. + mock_load_ep.assert_not_called() + + # ----------------------------------------------------------------------- + # T4c: Entry point resolving to a non-Provider class raises ImportError + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_get_class_external_ep_not_provider_subclass_raises_importerror( + self, mock_is_builtin, mock_ep + ): + """When an entry point resolves to an object that is not a Provider + subclass, get_class raises ImportError instead of returning it, so the + public contract (a Provider subclass) is enforced rather than trusted.""" + + class NotAProvider: + pass + + mock_is_builtin.return_value = False + mock_ep.return_value = [ + _make_entry_point("rogue", "pkg:NotAProvider", "prowler.providers"), + ] + mock_ep.return_value[0].load.return_value = NotAProvider + + with pytest.raises(ImportError): + Provider.get_class("rogue") + + # ----------------------------------------------------------------------- + # T4d: Contract — every built-in provider stays resolvable via get_class + # ----------------------------------------------------------------------- + + @pytest.mark.parametrize( + "provider", + [ + name + for name in Provider.get_available_providers() + if Provider.is_builtin(name) + ], + ) + def test_get_class_resolves_every_builtin_provider(self, provider): + """Contract test over all built-in providers: each one must remain + resolvable through get_class and return a Provider subclass whose name + follows the `{Capitalized}Provider` convention. This pins the naming + convention as the built-in resolution contract, so a future provider + that breaks it fails here instead of silently at runtime in a caller + (e.g. the API).""" + cls = Provider.get_class(provider) + + assert isinstance(cls, type) and issubclass(cls, Provider) + assert cls.__name__ == f"{provider.capitalize()}Provider" + + # ----------------------------------------------------------------------- + # T5: Regression — init_global_provider still resolves external correctly + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + def test_init_global_provider_still_resolves_external_via_get_class( + self, mock_load_ep, mock_config + ): + """Regression: init_global_provider continues to work for external providers + after the class-resolution block is delegated to get_class. + + 'fakepure' is not a built-in, so is_builtin() returns False and get_class + takes the entry-point path. This verifies the FakePureContractProvider path + (pure from_cli_args returning an instance) still works — i.e., + init_global_provider correctly wires the returned instance as global provider. + """ + mock_load_ep.return_value = FakePureContractProvider + mock_config.return_value = {} + + args = Namespace( + provider="fakepure", + fixer_config="config.yaml", + config_file="config.yaml", + ) + + Provider._global = None + Provider.init_global_provider(args) + + assert isinstance(Provider._global, FakePureContractProvider) + Provider._global = None + + # ----------------------------------------------------------------------- + # T6: Regression — get_providers_help_text returns same text after refactor + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.Provider._load_ep_provider") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_identical_after_refactor_external( + self, mock_providers, mock_load_ep + ): + """get_providers_help_text returns identical _cli_help_text for an external + provider both before and after the refactor to use get_class internally.""" + mock_providers.return_value = ["fakeexternal"] + mock_load_ep.return_value = FakeExternalProvider + + help_text = Provider.get_providers_help_text() + + # Must match the known _cli_help_text on FakeExternalProvider + assert help_text["fakeexternal"] == "Fake External Provider" + + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + @patch("prowler.providers.common.provider.Provider.get_available_providers") + def test_get_providers_help_text_identical_after_refactor_builtin( + self, mock_providers, mock_is_builtin, mock_import + ): + """get_providers_help_text returns identical _cli_help_text for a built-in + provider both before and after the refactor to use get_class internally. + is_builtin is mocked to True so get_class takes the built-in import path.""" + import types + + mock_providers.return_value = ["fakebuiltin"] + mock_is_builtin.return_value = True + mock_cls = type( + "FakebuiltinProvider", (Provider,), {"_cli_help_text": "Built-in Help"} + ) + mock_module = types.ModuleType("fake_module") + mock_module.FakebuiltinProvider = mock_cls + mock_import.return_value = mock_module + + help_text = Provider.get_providers_help_text() + + assert help_text["fakebuiltin"] == "Built-in Help" + + # ----------------------------------------------------------------------- + # T7: init_global_provider emits collision warning (not get_class) + # ----------------------------------------------------------------------- + + @patch("prowler.providers.common.provider.load_and_validate_config_file") + @patch("prowler.providers.common.provider.importlib.metadata.entry_points") + @patch("prowler.providers.common.provider.import_module") + @patch("prowler.providers.common.provider.Provider.is_builtin") + def test_init_global_provider_emits_collision_warning_for_builtin_ep_shadow( + self, mock_is_builtin, mock_import, mock_entry_points, mock_config, caplog + ): + """init_global_provider (not get_class) emits the collision warning + when a built-in provider has a same-named entry-point plug-in registered. + + This is the counterpart to test_get_class_builtin_with_ep_shadow_is_pure: + the warning responsibility moved OUT of get_class and INTO + init_global_provider, so users still see the message on CLI invocation + but prowler --help and API calls (which never hit init_global_provider) + do not spuriously emit it. The shadow is detected by entry-point name + only — the plug-in is never loaded to warn. + """ + import logging + import types + + mock_is_builtin.return_value = True + shadow_ep = MagicMock() + shadow_ep.name = "aws" # plug-in shadowing the built-in name + mock_entry_points.return_value = [shadow_ep] + + fake_module = types.ModuleType("fake_builtin_module") + fake_module.AwsProvider = MagicMock(side_effect=lambda **_kw: None) + mock_import.return_value = fake_module + mock_config.return_value = {} + + args = Namespace( + provider="aws", + fixer_config="config.yaml", + config_file="config.yaml", + aws_retries_max_attempts=3, + role=None, + session_duration=None, + external_id=None, + role_session_name=None, + mfa=None, + profile=None, + region=None, + excluded_region=None, + organizations_role=None, + scan_unused_services=False, + resource_tag=None, + resource_arn=None, + mutelist_file=None, + ) + + Provider._global = None + with caplog.at_level(logging.WARNING, logger="prowler"): + try: + Provider.init_global_provider(args) + except BaseException: + # AwsProvider mock is fake; dispatch may fail — only the + # warning emitted BEFORE dispatch matters here. + pass + Provider._global = None + + collision_warnings = [ + r.message + for r in caplog.records + if "Plug-in provider 'aws'" in r.message and "IGNORED" in r.message + ] + assert collision_warnings, ( + "init_global_provider must emit the collision warning when a " + "same-named EP plug-in exists for a built-in provider" + ) + # Shadow detected by name only — the plug-in is never loaded to warn. + shadow_ep.load.assert_not_called() diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index ba6480ee22..1abcaf5bf0 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -722,6 +722,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): }, "backupConfiguration": {"enabled": True}, "databaseFlags": [], + "availabilityType": "REGIONAL", }, }, { @@ -737,6 +738,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): }, "backupConfiguration": {"enabled": False}, "databaseFlags": [], + "availabilityType": "ZONAL", }, }, ] diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py index 7d2bea9f88..7b30b828da 100644 --- a/tests/providers/gcp/gcp_provider_test.py +++ b/tests/providers/gcp/gcp_provider_test.py @@ -13,6 +13,7 @@ from prowler.config.config import ( ) from prowler.providers.common.models import Connection from prowler.providers.gcp.exceptions.exceptions import ( + GCPGetOrganizationProjectsError, GCPInvalidProviderIdError, GCPNoAccesibleProjectsError, GCPTestConnectionError, @@ -1077,3 +1078,66 @@ class TestGCPProvider: assert gcp_provider.skip_api_check is True mocked_is_api_active.assert_not_called() + + def test_get_projects_organization_id_permission_denied_raises(self): + """When --organization-id is set and the Cloud Asset API returns a 403, + get_projects must raise GCPGetOrganizationProjectsError instead of + silently falling back to the service account's home project. + + Regression test for https://github.com/prowler-cloud/prowler/issues/11250. + """ + from googleapiclient.errors import HttpError + + forbidden_response = MagicMock(status=403, reason="Forbidden") + http_error = HttpError( + resp=forbidden_response, + content=b'{"error": {"code": 403, "message": "Permission denied on resource organization"}}', + uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets", + ) + + asset_service = MagicMock() + asset_service.assets.return_value.list.return_value.execute.side_effect = ( + http_error + ) + + with patch( + "prowler.providers.gcp.gcp_provider.discovery.build", + return_value=asset_service, + ): + with pytest.raises(GCPGetOrganizationProjectsError): + GcpProvider.get_projects( + credentials=MagicMock(), + organization_id="test-organization-id", + credentials_file="test_credentials_file", + ) + + def test_get_projects_organization_id_cloud_asset_api_disabled_raises(self): + """When --organization-id is set and the Cloud Asset API is disabled, + get_projects must raise GCPGetOrganizationProjectsError with the + enable-API remediation rather than swallowing the error.""" + from googleapiclient.errors import HttpError + + disabled_response = MagicMock(status=403, reason="Forbidden") + http_error = HttpError( + resp=disabled_response, + content=b'{"error": {"message": "Cloud Asset API has not been used in project 123 before or it is disabled."}}', + uri="https://cloudasset.googleapis.com/v1/organizations/123:listAssets", + ) + + asset_service = MagicMock() + asset_service.assets.return_value.list.return_value.execute.side_effect = ( + http_error + ) + + with patch( + "prowler.providers.gcp.gcp_provider.discovery.build", + return_value=asset_service, + ): + with pytest.raises(GCPGetOrganizationProjectsError) as exc_info: + GcpProvider.get_projects( + credentials=MagicMock(), + organization_id="test-organization-id", + credentials_file="test_credentials_file", + ) + + assert "Cloud Asset API" in str(exc_info.value) diff --git a/tests/providers/gcp/services/cloudfunction/__init__.py b/tests/providers/gcp/services/cloudfunction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py new file mode 100644 index 0000000000..8428e56d86 --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py @@ -0,0 +1,208 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.cloudfunction." + "cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc" +) +_CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" + + +def _function_id(name: str) -> str: + return ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/functions/{name}" + ) + + +class Test_cloudfunction_function_inside_vpc: + def test_no_functions(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + + cloudfunction_client.functions = [] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 + + def test_function_with_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + connector = ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/connectors/my-connector" + ) + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-vpc"), + name="fn-vpc", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=connector, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Cloud Function fn-vpc is connected to a VPC via connector: {connector}." + ) + assert result[0].resource_id == "fn-vpc" + assert result[0].resource_name == "fn-vpc" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_without_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-public"), + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cloud Function fn-public is not connected to any VPC network." + ) + assert result[0].resource_id == "fn-public" + assert result[0].resource_name == "fn-public" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_with_empty_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-empty"), + name="fn-empty", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector="", + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_inactive_function_skipped(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-deploy"), + name="fn-deploy", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="DEPLOYING", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py new file mode 100644 index 0000000000..615890bd58 --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_not_publicly_accessible/cloudfunction_function_not_publicly_accessible_test.py @@ -0,0 +1,216 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.cloudfunction." + "cloudfunction_function_not_publicly_accessible." + "cloudfunction_function_not_publicly_accessible" +) +_CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" + + +def _function_id(name: str) -> str: + return ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/functions/{name}" + ) + + +class Test_cloudfunction_function_not_publicly_accessible: + def test_no_functions(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + + cloudfunction_client.functions = [] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 + + def test_function_private(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-private"), + name="fn-private", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=False, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Cloud Function fn-private is not publicly accessible." + ) + assert result[0].resource_id == "fn-private" + assert result[0].resource_name == "fn-private" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_publicly_accessible(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-public"), + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=True, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Cloud Function fn-public is publicly invocable " + "(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + assert result[0].resource_id == "fn-public" + assert result[0].resource_name == "fn-public" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_functions_mixed(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-private"), + name="fn-private", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=False, + ), + Function( + id=_function_id("fn-public"), + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + publicly_accessible=True, + ), + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 2 + + by_id = {r.resource_id: r for r in result} + assert by_id["fn-private"].status == "PASS" + assert by_id["fn-public"].status == "FAIL" + + def test_inactive_function_skipped(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_not_publicly_accessible.cloudfunction_function_not_publicly_accessible import ( + cloudfunction_function_not_publicly_accessible, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + id=_function_id("fn-deleting"), + name="fn-deleting", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="DELETING", + publicly_accessible=True, + ) + ] + + check = cloudfunction_function_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py new file mode 100644 index 0000000000..d97b80336b --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py @@ -0,0 +1,319 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + +_LOCATION_ID = "us-central1" +_FUNCTION_NAME = "my-function" +_FUNCTION_ID = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/{_FUNCTION_NAME}" +) +_RUN_SERVICE = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/services/{_FUNCTION_NAME}" +) +_CONNECTOR = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/connectors/my-connector" +) + + +def _make_cloudfunction_client(functions_list, iam_bindings=None): + """Return a mock GCP API client for the Cloud Functions v2 service.""" + client = MagicMock() + + client.projects().locations().list().execute.return_value = { + "locations": [{"locationId": _LOCATION_ID}] + } + client.projects().locations().list_next.return_value = None + + client.projects().locations().functions().list().execute.return_value = { + "functions": functions_list + } + client.projects().locations().functions().list_next.return_value = None + + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().locations().functions().getIamPolicy = mock_get_iam_policy + + return client + + +def _make_run_client(iam_bindings=None): + """Return a mock Cloud Run v2 client for gen2 IAM policy lookups.""" + client = MagicMock() + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().locations().services().getIamPolicy = mock_get_iam_policy + return client + + +class TestCloudFunctionService: + def test_get_functions_with_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": { + "service": _RUN_SERVICE, + "vpcConnector": _CONNECTOR, + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=_make_run_client(), + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.id == _FUNCTION_ID + assert fn.name == _FUNCTION_NAME + assert fn.project_id == GCP_PROJECT_ID + assert fn.location == _LOCATION_ID + assert fn.state == "ACTIVE" + assert fn.environment == "GEN_2" + assert fn.service == _RUN_SERVICE + assert fn.vpc_connector == _CONNECTOR + assert fn.publicly_accessible is False + + def test_get_functions_without_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/no-vpc-func", + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": { + "service": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/services/no-vpc-func", + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=_make_run_client(), + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == "no-vpc-func" + assert fn.vpc_connector is None + assert fn.publicly_accessible is False + + def test_get_functions_iam_policy_gen2_all_users(self): + """Gen2 functions: allUsers binding lives on the Cloud Run service.""" + + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["allUsers"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is True + + def test_get_functions_iam_policy_gen2_all_authenticated_users(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["allAuthenticatedUsers"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is True + + def test_get_functions_iam_policy_gen2_not_public(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_2", + "serviceConfig": {"service": _RUN_SERVICE}, + } + ] + ) + + run_client = _make_run_client( + iam_bindings=[ + { + "role": "roles/run.invoker", + "members": ["serviceAccount:sa@project.iam.gserviceaccount.com"], + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + patch( + "prowler.providers.gcp.services.cloudfunction.cloudfunction_service.discovery.build", + return_value=run_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].publicly_accessible is False + + def test_get_functions_iam_policy_gen1_all_users(self): + """Gen1 functions: IAM binding lives on the Cloud Functions resource itself.""" + + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": _FUNCTION_ID, + "state": "ACTIVE", + "environment": "GEN_1", + "serviceConfig": {}, + } + ], + iam_bindings=[ + { + "role": "roles/cloudfunctions.invoker", + "members": ["allUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + assert cf_client.functions[0].environment == "GEN_1" + assert cf_client.functions[0].publicly_accessible is True diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py new file mode 100644 index 0000000000..82c49fb89e --- /dev/null +++ b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_EU1_LOCATION, + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + + +class Test_cloudsql_instance_high_availability_enabled: + """Tests for the cloudsql_instance_high_availability_enabled check.""" + + def test_no_instances(self): + """No Cloud SQL instances → no findings.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + + cloudsql_client.instances = [] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_ha_enabled(self): + """A REGIONAL primary instance → PASS.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-ha", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="REGIONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "db-ha" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_ha_disabled(self): + """A ZONAL primary instance → FAIL with current availability in status_extended.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-zonal", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended + assert result[0].resource_id == "db-zonal" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_read_replica_skipped(self): + """Read replicas (instance_type != CLOUD_SQL_INSTANCE) are skipped.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-replica", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + instance_type="READ_REPLICA_INSTANCE", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_default_availability_type_fails(self): + """An instance missing availabilityType defaults to ZONAL (service layer) and must FAIL.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-default", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + # availability_type omitted → model default "ZONAL" + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended diff --git a/tests/providers/gcp/services/logging/logging_service_test.py b/tests/providers/gcp/services/logging/logging_service_test.py index 72e18eddbb..e466a17314 100644 --- a/tests/providers/gcp/services/logging/logging_service_test.py +++ b/tests/providers/gcp/services/logging/logging_service_test.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, patch +import pytest + from prowler.providers.gcp.services.logging.logging_service import Logging from tests.providers.gcp.gcp_fixtures import ( GCP_PROJECT_ID, @@ -291,6 +293,93 @@ class TestGetProjectsCoveredByAggregatedMetric: ) assert self._run(logging_client, monitoring_client) == {} + def test_not_covered_when_sink_filter_omits_activity_stream(self): + """A sink that routes cloudaudit streams but NOT Admin Activity (here, + data_access only) does not deliver the entries the CIS metric filters + match, so it must not be credited — right service, wrong stream.""" + logging_client, monitoring_client = self._clients( + sink_filter="logName: /logs/cloudaudit.googleapis.com%2Fdata_access" + ) + assert self._run(logging_client, monitoring_client) == {} + + def test_covered_when_sink_filter_carries_activity_stream_encoded(self): + """A sink filtered to the cloudaudit streams (URL-encoded logName form, + as returned by the Logging API) delivers every Admin Activity entry the + CIS metric filters can match, so it must be credited.""" + logging_client, monitoring_client = self._clients( + sink_filter=( + "logName: /logs/cloudaudit.googleapis.com%2Factivity OR " + "logName: /logs/cloudaudit.googleapis.com%2Fdata_access" + ) + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_covered_when_sink_filter_carries_activity_stream_plain(self): + logging_client, monitoring_client = self._clients( + sink_filter='logName="projects/p/logs/cloudaudit.googleapis.com/activity"' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + @pytest.mark.parametrize( + "sink_filter", + [ + # --- Negation: the stream is named but excluded. --- + 'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + '-logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + 'NOT log_id("cloudaudit.googleapis.com/activity")', + # "!=" inequality (and its spaced form) excludes the stream. + 'logName!="projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + 'logName != "projects/p/logs/cloudaudit.googleapis.com/activity"', + # Activity negated inside a compound filter. + 'resource.type="gce_instance" AND ' + 'NOT logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity"', + # --- Restriction: the stream is named but AND-narrowed, so only a + # subset of Admin Activity entries reaches the bucket. --- + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + 'AND resource.type="gce_instance"', + 'log_id("cloudaudit.googleapis.com/activity") ' + 'AND resource.type="gce_instance"', + 'logName="projects/p/logs/cloudaudit.googleapis.com/activity" ' + "AND severity>=ERROR", + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + 'AND protoPayload.methodName="SetIamPolicy"', + # --- OR-ed with a non-audit predicate: fail closed, since we credit + # only unions of provable Cloud Audit stream selectors. --- + 'logName:"projects/p/logs/cloudaudit.googleapis.com%2Factivity" ' + "OR severity>=ERROR", + ], + ) + def test_not_covered_when_sink_filter_negated_or_restrictive(self, sink_filter): + """A filter that names the Admin Activity stream but negates, narrows, or + mixes in an unprovable predicate is not credited — we credit only filters + we can prove deliver every Admin Activity entry the CIS metrics match.""" + logging_client, monitoring_client = self._clients(sink_filter=sink_filter) + assert self._run(logging_client, monitoring_client) == {} + + def test_covered_when_activity_logname_has_hyphenated_path(self): + """A hyphen in the project path must not be mistaken for the ``-`` (NOT) + negation operator — the activity stream is still delivered.""" + logging_client, monitoring_client = self._clients( + sink_filter='logName="projects/my-project/logs/cloudaudit.googleapis.com/activity"' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + + def test_covered_when_sink_filter_uses_log_id_selector(self): + """The ``log_id()`` form is an equivalent positive full-coverage selector + of the Admin Activity stream and is credited like the ``logName`` form.""" + logging_client, monitoring_client = self._clients( + sink_filter='log_id("cloudaudit.googleapis.com/activity")' + ) + assert self._run(logging_client, monitoring_client) == { + GCP_PROJECT_ID: "central-metric" + } + def test_not_covered_when_sink_destination_bucket_differs(self): logging_client, monitoring_client = self._clients( sink_destination="logging.googleapis.com/projects/x/locations/eu/buckets/other" diff --git a/tests/providers/gcp/services/secretmanager/__init__.py b/tests/providers/gcp/services/secretmanager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py new file mode 100644 index 0000000000..f925966f3c --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py @@ -0,0 +1,169 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.secretmanager." + "secretmanager_secret_not_publicly_accessible." + "secretmanager_secret_not_publicly_accessible" +) +_CLIENT_PATH = f"{_CHECK_PATH}.secretmanager_client" + + +def _secret_id(name: str) -> str: + return f"projects/{GCP_PROJECT_ID}/secrets/{name}" + + +class Test_secretmanager_secret_not_publicly_accessible: + def test_no_secrets(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + + secretmanager_client.secrets = [] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 + + def test_secret_private(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-private"), + name="secret-private", + project_id=GCP_PROJECT_ID, + publicly_accessible=False, + ) + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Secret secret-private is not publicly accessible." + ) + assert result[0].resource_id == "secret-private" + assert result[0].resource_name == "secret-private" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_secret_publicly_accessible(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-public"), + name="secret-public", + project_id=GCP_PROJECT_ID, + publicly_accessible=True, + ) + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Secret secret-public is publicly accessible " + "(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + assert result[0].resource_id == "secret-public" + assert result[0].resource_name == "secret-public" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_secrets_mixed(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-private"), + name="secret-private", + project_id=GCP_PROJECT_ID, + publicly_accessible=False, + ), + Secret( + id=_secret_id("secret-public"), + name="secret-public", + project_id=GCP_PROJECT_ID, + publicly_accessible=True, + ), + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 2 + + by_id = {r.resource_id: r for r in result} + assert by_id["secret-private"].status == "PASS" + assert by_id["secret-public"].status == "FAIL" diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py new file mode 100644 index 0000000000..213134c245 --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + SecretManager, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + + +def _make_secretmanager_client(secrets_list, iam_bindings=None): + """Return a mock GCP API client for the Secret Manager service.""" + client = MagicMock() + + client.projects().secrets().list().execute.return_value = {"secrets": secrets_list} + client.projects().secrets().list_next.return_value = None + + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().secrets().getIamPolicy = mock_get_iam_policy + + return client + + +class TestSecretManagerService: + def test_get_secrets(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/my-secret", + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + secret = sm_client.secrets[0] + assert secret.name == "my-secret" + assert secret.id == f"projects/{GCP_PROJECT_ID}/secrets/my-secret" + assert secret.project_id == GCP_PROJECT_ID + assert secret.location == "global" + assert secret.publicly_accessible is False + + def test_get_secrets_iam_policy_all_users(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/public-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["allUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is True + + def test_get_secrets_iam_policy_all_authenticated_users(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/auth-users-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["allAuthenticatedUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is True + + def test_get_secrets_iam_policy_not_public(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/private-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["user:alice@example.com"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is False diff --git a/tests/providers/kubernetes/services/core/conftest.py b/tests/providers/kubernetes/services/core/conftest.py new file mode 100644 index 0000000000..9b0becd33c --- /dev/null +++ b/tests/providers/kubernetes/services/core/conftest.py @@ -0,0 +1,74 @@ +import importlib +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +def make_container( + name="app", + image="nginx:1.25", + resources=None, + liveness_probe=None, + readiness_probe=None, +): + return Container( + name=name, + image=image, + command=None, + ports=None, + env=None, + security_context={}, + resources=resources, + liveness_probe=liveness_probe, + readiness_probe=readiness_probe, + ) + + +def make_pod( + containers=None, + init_containers=None, + ephemeral_containers=None, + name="test-pod", + uid="test-pod-uid", +): + return Pod( + name=name, + uid=uid, + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=False, + host_ipc=False, + host_network=False, + security_context={}, + containers=containers or {}, + init_containers=init_containers or {}, + ephemeral_containers=ephemeral_containers or {}, + ) + + +def make_core_client(pods): + core_client = mock.MagicMock() + core_client.pods = pods + return core_client + + +def run_check(module_path, class_name, core_client): + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch(f"{module_path}.core_client", new=core_client), + ): + check_module = importlib.import_module(module_path) + check = getattr(check_module, class_name)() + return check.execute() diff --git a/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py b/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py new file mode 100644 index 0000000000..41663daab3 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_cpu_limits_set/core_cpu_limits_set_test.py @@ -0,0 +1,82 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = ( + "prowler.providers.kubernetes.services.core.core_cpu_limits_set.core_cpu_limits_set" +) +CLASS = "core_cpu_limits_set" + + +class TestCoreCpuLimitsSet: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_cpu_limit_set_pass(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": "500m"}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod regular containers have CPU limits configured." + ) + + def test_cpu_limit_missing_fail(self): + pod = make_pod(containers={"app": make_container(resources=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a CPU limit configured." + ) + + def test_empty_cpu_limit_fail(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": ""}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container( + name="app", resources={"limits": {"cpu": "500m"}} + ), + "sidecar": make_container(name="sidecar", resources={"limits": {}}), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a CPU limit configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(resources={"limits": {"cpu": "500m"}})}, + init_containers={"init": make_container(name="init", resources=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", resources=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py b/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py new file mode 100644 index 0000000000..47fcec1b1e --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_cpu_requests_set/core_cpu_requests_set_test.py @@ -0,0 +1,80 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_cpu_requests_set.core_cpu_requests_set" +CLASS = "core_cpu_requests_set" + + +class TestCoreCpuRequestsSet: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_cpu_request_set_pass(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": "100m"}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod regular containers have CPU requests configured." + ) + + def test_cpu_request_missing_fail(self): + pod = make_pod(containers={"app": make_container(resources={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a CPU request configured." + ) + + def test_empty_cpu_request_fail(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": ""}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container( + name="app", resources={"requests": {"cpu": "100m"}} + ), + "sidecar": make_container(name="sidecar", resources=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a CPU request configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(resources={"requests": {"cpu": "100m"}})}, + init_containers={"init": make_container(name="init", resources=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", resources=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py b/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py new file mode 100644 index 0000000000..a5e62e8157 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_image_tag_fixed/core_image_tag_fixed_test.py @@ -0,0 +1,572 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed" +CLASS = "core_image_tag_fixed" + + +class Test_core_image_tag_fixed: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 0 + + def test_image_tag_fixed(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_pod_without_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) + + def test_image_tag_latest(self): + container = Container( + name="test-container", + image="nginx:latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_tag_blank(self): + container = Container( + name="test-container", + image="nginx", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_with_digest(self): + container = Container( + name="test-container", + image="nginx@sha256:abc123def456", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_image_with_registry_port_and_no_tag(self): + container = Container( + name="test-container", + image="localhost:5000/nginx", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_tag_uppercase_latest(self): + container = Container( + name="test-container", + image="nginx:Latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_image_with_empty_tag(self): + container = Container( + name="test-container", + image="nginx:", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not use a fixed tag" in result[0].status_extended + + def test_mixed_containers_fails_on_first_unfixed_image(self): + fixed_container = Container( + name="fixed-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + latest_container = Container( + name="latest-container", + image="busybox:latest", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "fixed-container": fixed_container, + "latest-container": latest_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_image_tag_fixed.core_image_tag_fixed import ( + core_image_tag_fixed, + ) + + check = core_image_tag_fixed() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container latest-container with image 'busybox:latest' that does not use a fixed tag." + ) + + def test_init_container_image_tag_latest(self): + pod = make_pod( + containers={"app": make_container(image="nginx:1.25.3")}, + init_containers={ + "init": make_container(name="init", image="busybox:latest") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container init with image 'busybox:latest' that does not use a fixed tag." + ) + + def test_ephemeral_container_image_tag_blank(self): + pod = make_pod( + containers={"app": make_container(image="nginx:1.25.3")}, + ephemeral_containers={ + "debug": make_container(name="debug", image="busybox") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod has container debug with image 'busybox' that does not use a fixed tag." + ) + + def test_init_and_ephemeral_image_tags_fixed_without_regular_containers(self): + pod = make_pod( + containers=None, + init_containers={"init": make_container(name="init", image="busybox:1.36")}, + ephemeral_containers={ + "debug": make_container(name="debug", image="debug@sha256:abc123") + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has fixed image tags on all containers." + ) diff --git a/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py b/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py new file mode 100644 index 0000000000..cc2d1915a5 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_liveness_probe_configured/core_liveness_probe_configured_test.py @@ -0,0 +1,83 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_liveness_probe_configured.core_liveness_probe_configured" +CLASS = "core_liveness_probe_configured" + + +class TestCoreLivenessProbeConfigured: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_liveness_probe_configured_pass(self): + pod = make_pod( + containers={"app": make_container(liveness_probe={"http_get": {}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod has liveness probes configured for all regular containers." + ) + + def test_liveness_probe_missing_fail(self): + pod = make_pod(containers={"app": make_container(liveness_probe=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a liveness probe configured." + ) + + def test_empty_liveness_probe_fail(self): + pod = make_pod(containers={"app": make_container(liveness_probe={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a liveness probe configured." + ) + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container(name="app", liveness_probe={"http_get": {}}), + "sidecar": make_container(name="sidecar", liveness_probe=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a liveness probe configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(liveness_probe={"http_get": {}})}, + init_containers={"init": make_container(name="init", liveness_probe=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", liveness_probe=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py b/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py new file mode 100644 index 0000000000..f3e37ae7f7 --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_memory_limits_set/core_memory_limits_set_test.py @@ -0,0 +1,313 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +class Test_core_memory_limits_set: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 0 + + def test_memory_limits_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi", "cpu": "500m"}, + "requests": {"memory": "64Mi", "cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory limits set on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_memory_limits_set_with_no_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory limits set on all containers." + ) + + def test_memory_limits_not_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory limits set on container test-container." + ) + + def test_memory_limits_missing_memory_key(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"cpu": "500m"}, + "requests": {"memory": "64Mi"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_mixed_containers_fails_on_missing_memory_limit(self): + limited_container = Container( + name="limited-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={"limits": {"memory": "128Mi"}}, + ) + unlimited_container = Container( + name="unlimited-container", + image="busybox:1.36", + command=None, + ports=None, + env=None, + security_context={}, + resources={"requests": {"memory": "64Mi"}}, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "limited-container": limited_container, + "unlimited-container": unlimited_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_limits_set.core_memory_limits_set import ( + core_memory_limits_set, + ) + + check = core_memory_limits_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory limits set on container unlimited-container." + ) diff --git a/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py b/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py new file mode 100644 index 0000000000..8da3225abd --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_memory_requests_set/core_memory_requests_set_test.py @@ -0,0 +1,313 @@ +from unittest import mock + +from prowler.providers.kubernetes.services.core.core_service import Container, Pod +from tests.providers.kubernetes.kubernetes_fixtures import ( + set_mocked_kubernetes_provider, +) + + +class Test_core_memory_requests_set: + def test_no_pods(self): + core_client = mock.MagicMock() + core_client.pods = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 0 + + def test_memory_requests_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi", "cpu": "500m"}, + "requests": {"memory": "64Mi", "cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory requests set on all containers." + ) + assert result[0].resource_id == "test-uid-1234" + assert result[0].resource_name == "test-pod" + + def test_memory_requests_set_with_no_containers(self): + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers=None, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Pod test-pod has memory requests set on all containers." + ) + + def test_memory_requests_not_set(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources=None, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory requests set on container test-container." + ) + + def test_memory_requests_missing_memory_key(self): + container = Container( + name="test-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={ + "limits": {"memory": "128Mi"}, + "requests": {"cpu": "250m"}, + }, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={"test-container": container}, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_mixed_containers_fails_on_missing_memory_request(self): + requested_container = Container( + name="requested-container", + image="nginx:1.25.3", + command=None, + ports=None, + env=None, + security_context={}, + resources={"requests": {"memory": "64Mi"}}, + ) + unrequested_container = Container( + name="unrequested-container", + image="busybox:1.36", + command=None, + ports=None, + env=None, + security_context={}, + resources={"limits": {"memory": "128Mi"}}, + ) + + pod = Pod( + name="test-pod", + uid="test-uid-1234", + namespace="default", + labels=None, + annotations=None, + node_name=None, + service_account=None, + status_phase="Running", + pod_ip="10.0.0.1", + host_ip="192.168.1.1", + host_pid=None, + host_ipc=None, + host_network=False, + security_context={}, + containers={ + "requested-container": requested_container, + "unrequested-container": unrequested_container, + }, + ) + + core_client = mock.MagicMock() + core_client.pods = {"test-uid-1234": pod} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_kubernetes_provider(), + ), + mock.patch( + "prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set.core_client", + new=core_client, + ), + ): + from prowler.providers.kubernetes.services.core.core_memory_requests_set.core_memory_requests_set import ( + core_memory_requests_set, + ) + + check = core_memory_requests_set() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Pod test-pod does not have memory requests set on container unrequested-container." + ) diff --git a/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py b/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py new file mode 100644 index 0000000000..ca316545ae --- /dev/null +++ b/tests/providers/kubernetes/services/core/core_readiness_probe_configured/core_readiness_probe_configured_test.py @@ -0,0 +1,83 @@ +from tests.providers.kubernetes.services.core.conftest import ( + make_container, + make_core_client, + make_pod, + run_check, +) + +MODULE = "prowler.providers.kubernetes.services.core.core_readiness_probe_configured.core_readiness_probe_configured" +CLASS = "core_readiness_probe_configured" + + +class TestCoreReadinessProbeConfigured: + def test_no_resources(self): + result = run_check(MODULE, CLASS, make_core_client({})) + + assert len(result) == 0 + + def test_readiness_probe_configured_pass(self): + pod = make_pod( + containers={"app": make_container(readiness_probe={"http_get": {}})} + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Pod test-pod has readiness probes configured for all regular containers." + ) + + def test_readiness_probe_missing_fail(self): + pod = make_pod(containers={"app": make_container(readiness_probe=None)}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a readiness probe configured." + ) + + def test_empty_readiness_probe_fail(self): + pod = make_pod(containers={"app": make_container(readiness_probe={})}) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container app does not have a readiness probe configured." + ) + + def test_mixed_regular_containers_fail(self): + pod = make_pod( + containers={ + "app": make_container(name="app", readiness_probe={"http_get": {}}), + "sidecar": make_container(name="sidecar", readiness_probe=None), + } + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Pod test-pod container sidecar does not have a readiness probe configured." + ) + + def test_init_and_ephemeral_containers_ignored(self): + pod = make_pod( + containers={"app": make_container(readiness_probe={"http_get": {}})}, + init_containers={"init": make_container(name="init", readiness_probe=None)}, + ephemeral_containers={ + "debug": make_container(name="debug", readiness_probe=None) + }, + ) + + result = run_check(MODULE, CLASS, make_core_client({pod.uid: pod})) + + assert result[0].status == "PASS" diff --git a/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml b/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml new file mode 100644 index 0000000000..60a3edef84 --- /dev/null +++ b/tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml @@ -0,0 +1,9 @@ +Mutelist: + Accounts: + "E1AF1B6C-1111-2222-3333-444455556666": + Checks: + "administration_user_2fa_enabled": + Regions: + - "*" + Resources: + - "admin" diff --git a/tests/providers/linode/lib/mutelist/linode_mutelist_test.py b/tests/providers/linode/lib/mutelist/linode_mutelist_test.py new file mode 100644 index 0000000000..f122e5bcae --- /dev/null +++ b/tests/providers/linode/lib/mutelist/linode_mutelist_test.py @@ -0,0 +1,98 @@ +from unittest.mock import MagicMock + +import yaml + +from prowler.providers.linode.lib.mutelist.mutelist import LinodeMutelist + +MUTELIST_FIXTURE_PATH = ( + "tests/providers/linode/lib/mutelist/fixtures/linode_mutelist.yaml" +) + + +class Test_linode_mutelist: + def test_get_mutelist_file_from_local_file(self): + mutelist = LinodeMutelist(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/linode/lib/mutelist/fixtures/not_present" + mutelist = LinodeMutelist(mutelist_path=mutelist_path) + + assert mutelist.mutelist == {} + assert mutelist.mutelist_file_path == mutelist_path + + def test_validate_mutelist_not_valid_key(self): + with open(MUTELIST_FIXTURE_PATH) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"] + del mutelist_fixture["Accounts"] + + mutelist = LinodeMutelist(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": { + "E1AF1B6C-1111-2222-3333-444455556666": { + "Checks": { + "administration_user_2fa_enabled": { + "Regions": ["*"], + "Resources": ["admin"], + } + } + } + } + } + + mutelist = LinodeMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "administration_user_2fa_enabled" + finding.status = "FAIL" + finding.region = "global" + finding.resource_id = "admin" + finding.resource_name = "admin" + finding.resource_tags = [] + + assert mutelist.is_finding_muted( + finding, "E1AF1B6C-1111-2222-3333-444455556666" + ) + + def test_is_finding_not_muted(self): + mutelist_content = { + "Accounts": { + "E1AF1B6C-1111-2222-3333-444455556666": { + "Checks": { + "administration_user_2fa_enabled": { + "Regions": ["*"], + "Resources": ["other-user"], + } + } + } + } + } + + mutelist = LinodeMutelist(mutelist_content=mutelist_content) + + finding = MagicMock() + finding.check_metadata = MagicMock() + finding.check_metadata.CheckID = "administration_user_2fa_enabled" + finding.status = "FAIL" + finding.region = "global" + finding.resource_id = "admin" + finding.resource_name = "admin" + finding.resource_tags = [] + + assert not mutelist.is_finding_muted( + finding, "E1AF1B6C-1111-2222-3333-444455556666" + ) diff --git a/tests/providers/linode/linode_fixtures.py b/tests/providers/linode/linode_fixtures.py new file mode 100644 index 0000000000..5786ffe881 --- /dev/null +++ b/tests/providers/linode/linode_fixtures.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock + +from prowler.providers.linode.models import ( + LinodeIdentityInfo, + LinodeSession, +) + +# Linode Identity +USERNAME = "admin" +EMAIL = "admin@example.com" +ACCOUNT_ID = "E1AF1B6C-1111-2222-3333-444455556666" + +# Linode Credentials +TOKEN = "fake-linode-token-for-testing" + + +def set_mocked_linode_provider( + username: str = USERNAME, + email: str = EMAIL, + account_id: str = ACCOUNT_ID, +): + """Return a mocked LinodeProvider with identity and session set.""" + provider = MagicMock() + provider.type = "linode" + provider.identity = LinodeIdentityInfo( + username=username, + email=email, + account_id=account_id, + ) + provider.session = LinodeSession( + client=MagicMock(), + token=TOKEN, + ) + return provider diff --git a/tests/providers/linode/linode_metadata_test.py b/tests/providers/linode/linode_metadata_test.py new file mode 100644 index 0000000000..9409c468af --- /dev/null +++ b/tests/providers/linode/linode_metadata_test.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from prowler.lib.check.models import CheckMetadata + +EXPECTED_SERVICE_NAMES = { + "administration": "administration", + "compute": "compute", + "networking": "networking", +} + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_check_metadata_is_valid(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + assert metadata.Provider == "linode" + assert metadata.CheckID == metadata_file.stem.replace(".metadata", "") + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_checks_metadata_use_canonical_hub_urls(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + url = metadata.Remediation.Recommendation.Url + assert not url.startswith( + "https://hub.prowler.com/checks/linode/" + ), f"{metadata_file}: non-canonical hub URL {url}" + + +@pytest.mark.parametrize( + "metadata_file", + sorted(Path("prowler/providers/linode").glob("services/**/*.metadata.json")), +) +def test_linode_check_metadata_uses_product_area_service_names(metadata_file): + metadata = CheckMetadata.parse_file(metadata_file) + service_folder = metadata_file.relative_to( + Path("prowler/providers/linode/services") + ).parts[0] + + assert metadata.ServiceName == EXPECTED_SERVICE_NAMES[service_folder] diff --git a/tests/providers/linode/linode_provider_test.py b/tests/providers/linode/linode_provider_test.py new file mode 100644 index 0000000000..67df6ccc20 --- /dev/null +++ b/tests/providers/linode/linode_provider_test.py @@ -0,0 +1,146 @@ +import os +from unittest import mock + +import pytest + +from prowler.providers.linode.exceptions.exceptions import ( + LinodeAuthenticationError, + LinodeCredentialsError, + LinodeInvalidRegionError, +) +from prowler.providers.linode.linode_provider import LinodeProvider +from prowler.providers.linode.models import LinodeIdentityInfo, LinodeSession +from tests.providers.linode.linode_fixtures import ( + ACCOUNT_ID, + EMAIL, + TOKEN, + USERNAME, +) + + +class TestLinodeProvider_setup_session: + def test_missing_token_raises_credentials_error(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": ""}, clear=False): + os.environ.pop("LINODE_TOKEN", None) + with pytest.raises(LinodeCredentialsError): + LinodeProvider.setup_session() + + def test_returns_session_with_token(self): + session = LinodeProvider.setup_session(token=TOKEN) + assert isinstance(session, LinodeSession) + assert session.token == TOKEN + assert session.client is not None + + def test_reads_token_from_env(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": TOKEN}, clear=False): + session = LinodeProvider.setup_session() + assert session.token == TOKEN + + +class TestLinodeProvider_setup_identity: + def _build_session(self): + client = mock.MagicMock() + return LinodeSession(client=client, token=TOKEN) + + def test_resolves_identity_from_profile_and_account(self): + session = self._build_session() + profile = mock.MagicMock() + profile.username = USERNAME + profile.email = EMAIL + session.client.profile.return_value = profile + + account = mock.MagicMock() + account.euuid = ACCOUNT_ID + session.client.account.return_value = account + + identity = LinodeProvider.setup_identity(session) + + assert isinstance(identity, LinodeIdentityInfo) + assert identity.username == USERNAME + assert identity.email == EMAIL + assert identity.account_id == ACCOUNT_ID + + def test_invalid_token_raises_authentication_error(self): + # An invalid token fails the profile call (any valid token can read its + # own profile), so the scan must abort instead of returning empty data. + session = self._build_session() + session.client.profile.side_effect = Exception("[401] Invalid Token") + + with pytest.raises(LinodeAuthenticationError): + LinodeProvider.setup_identity(session) + + def test_identity_with_account_failure_still_returns(self): + session = self._build_session() + profile = mock.MagicMock() + profile.username = USERNAME + profile.email = EMAIL + session.client.profile.return_value = profile + session.client.account.side_effect = Exception("forbidden") + + identity = LinodeProvider.setup_identity(session) + + assert identity.username == USERNAME + assert identity.email == EMAIL + assert identity.account_id is None + + +class TestLinodeProvider_test_connection: + def test_successful_connection(self): + with mock.patch( + "prowler.providers.linode.linode_provider.LinodeProvider.setup_session" + ) as mock_session: + session = mock.MagicMock() + session.client.profile.return_value = mock.MagicMock() + mock_session.return_value = session + + conn = LinodeProvider.test_connection(token=TOKEN, raise_on_exception=False) + + assert conn.is_connected is True + + def test_missing_credentials(self): + with mock.patch.dict(os.environ, {"LINODE_TOKEN": ""}, clear=False): + os.environ.pop("LINODE_TOKEN", None) + conn = LinodeProvider.test_connection(token=None, raise_on_exception=False) + assert conn.is_connected is False + + def test_connection_failure_raises_when_requested(self): + with mock.patch( + "prowler.providers.linode.linode_provider.LinodeProvider.setup_session" + ) as mock_session: + mock_session.side_effect = LinodeCredentialsError( + file="test", message="No token" + ) + with pytest.raises(LinodeCredentialsError): + LinodeProvider.test_connection(token=None, raise_on_exception=True) + + +class TestLinodeProvider_validate_regions: + def _session_with_regions(self, region_ids): + client = mock.MagicMock() + client.regions.return_value = [mock.MagicMock(id=rid) for rid in region_ids] + return LinodeSession(client=client, token=TOKEN) + + def test_no_regions_returns_none(self): + session = self._session_with_regions(["eu-central", "us-east"]) + assert LinodeProvider.validate_regions(session, None) is None + + def test_valid_regions_returns_set(self): + session = self._session_with_regions(["eu-central", "us-east", "ap-south"]) + result = LinodeProvider.validate_regions(session, ["eu-central", "us-east"]) + assert result == {"eu-central", "us-east"} + + def test_invalid_region_raises(self): + session = self._session_with_regions(["eu-central", "us-east"]) + with pytest.raises(LinodeInvalidRegionError): + LinodeProvider.validate_regions(session, ["eu-central", "nonexistent"]) + + def test_regions_api_failure_does_not_block(self): + # If the public regions list cannot be fetched, the scan proceeds with + # the requested regions instead of failing. + client = mock.MagicMock() + client.regions.side_effect = Exception("regions API error") + session = LinodeSession(client=client, token=TOKEN) + + result = LinodeProvider.validate_regions(session, ["eu-central"]) + + assert result == {"eu-central"} diff --git a/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py b/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py new file mode 100644 index 0000000000..a4e77c662a --- /dev/null +++ b/tests/providers/linode/services/administration/administration_user_2fa_enabled/administration_user_2fa_enabled_test.py @@ -0,0 +1,97 @@ +from unittest import mock + +from prowler.providers.linode.services.administration.administration_service import ( + User, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_administration_user_2fa_enabled: + def test_no_users(self): + administration_client = mock.MagicMock() + administration_client.users = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_user_with_2fa(self): + administration_client = mock.MagicMock() + administration_client.users = [ + User( + username="admin", + email="admin@example.com", + tfa_enabled=True, + restricted=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "admin" + assert result[0].resource_id == "admin" + + def test_user_without_2fa(self): + administration_client = mock.MagicMock() + administration_client.users = [ + User( + username="dev-user", + email="dev@example.com", + tfa_enabled=False, + restricted=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled.administration_client", + new=administration_client, + ), + ): + from prowler.providers.linode.services.administration.administration_user_2fa_enabled.administration_user_2fa_enabled import ( + administration_user_2fa_enabled, + ) + + check = administration_user_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "dev-user" + assert result[0].resource_id == "dev-user" diff --git a/tests/providers/linode/services/administration/linode_administration_service_test.py b/tests/providers/linode/services/administration/linode_administration_service_test.py new file mode 100644 index 0000000000..b8393c073a --- /dev/null +++ b/tests/providers/linode/services/administration/linode_administration_service_test.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.administration.administration_service import ( + AdministrationService, +) + + +def _mock_user(username="admin", email="admin@example.com", tfa=True, restricted=False): + user = MagicMock() + user.username = username + user.email = email + user.tfa_enabled = tfa + user.restricted = restricted + return user + + +def _build_service(account_users_return=None, account_users_side_effect=None): + """Build an AdministrationService with an isolated mock client.""" + service = object.__new__(AdministrationService) + service.users = [] + + # Build isolated mock hierarchy for client.account.users() + # Must explicitly create the users callable as a fresh MagicMock + # because check tests contaminate MagicMock class with users=[...] + users_callable = MagicMock() + if account_users_side_effect: + users_callable.side_effect = account_users_side_effect + else: + users_callable.return_value = account_users_return or [] + + account_mock = MagicMock() + account_mock.users = users_callable + + client_mock = MagicMock() + client_mock.account = account_mock + service.client = client_mock + return service + + +class TestLinodeAdministrationService: + def test_describe_users_parses_correctly(self): + mock_users = [ + _mock_user("admin", "admin@example.com", True, False), + _mock_user("reader", "reader@example.com", False, True), + ] + + service = _build_service(account_users_return=mock_users) + service._describe_users() + + assert len(service.users) == 2 + assert service.users[0].username == "admin" + assert service.users[0].tfa_enabled is True + assert service.users[1].username == "reader" + assert service.users[1].restricted is True + + def test_describe_users_handles_empty_list(self): + service = _build_service(account_users_return=[]) + service._describe_users() + + assert len(service.users) == 0 + + def test_describe_users_handles_api_error(self): + service = _build_service(account_users_side_effect=Exception("API error")) + service._describe_users() + + assert len(service.users) == 0 + + def test_describe_users_handles_null_fields(self): + """A user with null tfa_enabled/email must still be included with safe + defaults instead of being dropped by a ValidationError.""" + user = MagicMock() + user.username = "partial" + user.email = None + user.tfa_enabled = None + user.restricted = None + + service = _build_service(account_users_return=[user]) + service._describe_users() + + assert len(service.users) == 1 + assert service.users[0].username == "partial" + assert service.users[0].email == "" + assert service.users[0].tfa_enabled is False + assert service.users[0].restricted is False + + def test_describe_users_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(account_users_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_users() + + assert len(service.users) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "account:read_only" in logged diff --git a/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py new file mode 100644 index 0000000000..93e19fa6b1 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_backups_enabled/compute_instance_backups_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_backups_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_backups_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + backups_enabled=True, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has the Backup service enabled." + ) + + def test_instance_backups_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + backups_enabled=False, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_backups_enabled.compute_instance_backups_enabled import ( + compute_instance_backups_enabled, + ) + + check = compute_instance_backups_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have the Backup service enabled." + ) diff --git a/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py new file mode 100644 index 0000000000..d5023d2f94 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_disk_encryption_enabled/compute_instance_disk_encryption_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_disk_encryption_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_disk_encryption_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + disk_encryption="enabled", + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has disk encryption enabled." + ) + + def test_instance_disk_encryption_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + disk_encryption="disabled", + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_disk_encryption_enabled.compute_instance_disk_encryption_enabled import ( + compute_instance_disk_encryption_enabled, + ) + + check = compute_instance_disk_encryption_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have disk encryption enabled." + ) diff --git a/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py b/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py new file mode 100644 index 0000000000..db5ddaacd0 --- /dev/null +++ b/tests/providers/linode/services/compute/compute_instance_watchdog_enabled/compute_instance_watchdog_enabled_test.py @@ -0,0 +1,109 @@ +from unittest import mock + +from prowler.providers.linode.services.compute.compute_service import ( + Instance, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_compute_instance_watchdog_enabled: + def test_no_instances(self): + compute_client = mock.MagicMock() + compute_client.instances = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_compute_instance_watchdog_enabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + watchdog_enabled=True, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central has Watchdog (Lassie) enabled." + ) + + def test_instance_watchdog_disabled(self): + compute_client = mock.MagicMock() + compute_client.instances = [ + Instance( + id=12345, + label="ubuntu-eu-central", + region="us-east", + status="running", + watchdog_enabled=False, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.linode.services.compute.compute_instance_watchdog_enabled.compute_instance_watchdog_enabled import ( + compute_instance_watchdog_enabled, + ) + + check = compute_instance_watchdog_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "12345" + assert result[0].resource_name == "ubuntu-eu-central" + assert ( + result[0].status_extended + == "Instance ubuntu-eu-central does not have Watchdog (Lassie) enabled." + ) diff --git a/tests/providers/linode/services/compute/linode_compute_service_test.py b/tests/providers/linode/services/compute/linode_compute_service_test.py new file mode 100644 index 0000000000..09b1a54e84 --- /dev/null +++ b/tests/providers/linode/services/compute/linode_compute_service_test.py @@ -0,0 +1,143 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.compute.compute_service import ( + ComputeService, +) + + +def _mock_instance( + id=1, + label="my-instance", + region="us-east", + status="running", + backups_enabled=True, + disk_encryption="enabled", + watchdog_enabled=True, + tags=None, +): + inst = MagicMock() + inst.id = id + inst.label = label + region_mock = MagicMock() + region_mock.id = region + inst.region = region_mock + inst.status = status + backups = MagicMock() + backups.enabled = backups_enabled + inst.backups = backups + inst.disk_encryption = disk_encryption + inst.watchdog_enabled = watchdog_enabled + inst.tags = tags or [] + return inst + + +def _build_service(linode_instances_return=None, linode_instances_side_effect=None): + """Build a ComputeService with an isolated mock client.""" + service = object.__new__(ComputeService) + service.instances = [] + + # Build isolated mock hierarchy for client.linode.instances() + # Must explicitly create the instances callable as a fresh MagicMock + # because check tests contaminate MagicMock class with instances=[...] + instances_callable = MagicMock() + if linode_instances_side_effect: + instances_callable.side_effect = linode_instances_side_effect + else: + instances_callable.return_value = linode_instances_return or [] + + linode_mock = MagicMock() + linode_mock.instances = instances_callable + + client_mock = MagicMock() + client_mock.linode = linode_mock + service.client = client_mock + return service + + +class TestLinodeComputeService: + def test_describe_instances_parses_correctly(self): + mock_instances = [ + _mock_instance(id=1, label="web-1", region="us-east"), + _mock_instance(id=2, label="db-1", region="eu-west", backups_enabled=False), + ] + + service = _build_service(linode_instances_return=mock_instances) + service._describe_instances() + + assert len(service.instances) == 2 + assert service.instances[0].label == "web-1" + assert service.instances[0].region == "us-east" + assert service.instances[0].backups_enabled is True + assert service.instances[1].label == "db-1" + assert service.instances[1].backups_enabled is False + + def test_describe_instances_handles_empty_list(self): + service = _build_service(linode_instances_return=[]) + service._describe_instances() + + assert len(service.instances) == 0 + + def test_describe_instances_handles_api_error(self): + service = _build_service(linode_instances_side_effect=Exception("API error")) + service._describe_instances() + + assert len(service.instances) == 0 + + def test_describe_instances_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(linode_instances_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_instances() + + assert len(service.instances) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "linodes:read_only" in logged + + def test_describe_instances_disk_encryption(self): + mock_instances = [ + _mock_instance(id=1, disk_encryption="enabled"), + _mock_instance(id=2, disk_encryption="disabled"), + ] + + service = _build_service(linode_instances_return=mock_instances) + service._describe_instances() + + assert service.instances[0].disk_encryption == "enabled" + assert service.instances[1].disk_encryption == "disabled" + + def test_describe_instances_region_filter_keeps_only_matching(self): + mock_instances = [ + _mock_instance(id=1, label="eu", region="eu-central"), + _mock_instance(id=2, label="us", region="us-east"), + _mock_instance(id=3, label="eu-2", region="eu-central"), + ] + service = _build_service(linode_instances_return=mock_instances) + service.provider = MagicMock() + service.provider.regions = {"eu-central"} + + service._describe_instances() + + assert len(service.instances) == 2 + assert {i.label for i in service.instances} == {"eu", "eu-2"} + assert all(i.region == "eu-central" for i in service.instances) + + def test_describe_instances_no_region_filter_keeps_all(self): + mock_instances = [ + _mock_instance(id=1, region="eu-central"), + _mock_instance(id=2, region="us-east"), + ] + service = _build_service(linode_instances_return=mock_instances) + service.provider = MagicMock() + service.provider.regions = None + + service._describe_instances() + + assert len(service.instances) == 2 diff --git a/tests/providers/linode/services/networking/linode_networking_service_test.py b/tests/providers/linode/services/networking/linode_networking_service_test.py new file mode 100644 index 0000000000..cd4f43f423 --- /dev/null +++ b/tests/providers/linode/services/networking/linode_networking_service_test.py @@ -0,0 +1,193 @@ +from unittest.mock import MagicMock, patch + +from linode_api4.errors import ApiError + +from prowler.providers.linode.services.networking.networking_service import ( + NetworkingService, +) + + +def _mock_rule( + protocol="TCP", ports="22", ipv4=None, ipv6=None, action="ACCEPT", label="" +): + rule = MagicMock() + rule.protocol = protocol + rule.ports = ports + rule.action = action + rule.label = label + addresses = MagicMock() + addresses.ipv4 = ipv4 or [] + addresses.ipv6 = ipv6 or [] + rule.addresses = addresses + return rule + + +def _mock_firewall( + id=1, label="my-fw", status="enabled", inbound=None, outbound=None, tags=None +): + fw = MagicMock() + fw.id = id + fw.label = label + fw.status = status + fw.tags = tags or [] + rules = MagicMock() + rules.inbound = inbound or [] + rules.outbound = outbound or [] + rules.inbound_policy = "DROP" + rules.outbound_policy = "DROP" + fw.rules = rules + return fw + + +def _build_service( + networking_firewalls_return=None, networking_firewalls_side_effect=None +): + """Build a NetworkingService instance with a properly isolated mock client.""" + service = object.__new__(NetworkingService) + service.firewalls = [] + + firewalls_callable = MagicMock() + if networking_firewalls_side_effect: + firewalls_callable.side_effect = networking_firewalls_side_effect + else: + firewalls_callable.return_value = networking_firewalls_return or [] + + networking_mock = MagicMock() + networking_mock.firewalls = firewalls_callable + + client_mock = MagicMock() + client_mock.networking = networking_mock + service.client = client_mock + return service + + +class TestLinodeNetworkingService: + def test_describe_firewalls_parses_correctly(self): + inbound_rules = [ + _mock_rule("TCP", "22", ipv4=["192.168.1.0/24"]), + _mock_rule("TCP", "443", ipv4=["0.0.0.0/0"]), + ] + mock_fws = [ + _mock_firewall(id=1, label="prod-fw", inbound=inbound_rules), + ] + + service = _build_service(networking_firewalls_return=mock_fws) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].label == "prod-fw" + assert len(service.firewalls[0].inbound_rules) == 2 + assert service.firewalls[0].inbound_rules[0].ports == "22" + assert service.firewalls[0].inbound_rules[0].addresses_ipv4 == [ + "192.168.1.0/24" + ] + assert service.firewalls[0].inbound_rules[1].addresses_ipv4 == ["0.0.0.0/0"] + + def test_describe_firewalls_handles_empty_list(self): + service = _build_service(networking_firewalls_return=[]) + service._describe_firewalls() + + assert len(service.firewalls) == 0 + + def test_describe_firewalls_handles_api_error(self): + service = _build_service( + networking_firewalls_side_effect=Exception("API error") + ) + service._describe_firewalls() + + assert len(service.firewalls) == 0 + + def test_describe_firewalls_missing_scope_logs_permission_error(self): + error = ApiError( + "Your OAuth token is not authorized to use this endpoint.", status=401 + ) + service = _build_service(networking_firewalls_side_effect=error) + + with patch( + "prowler.providers.linode.lib.service.service.logger" + ) as logger_mock: + service._describe_firewalls() + + assert len(service.firewalls) == 0 + logged = " ".join(str(c) for c in logger_mock.error.call_args_list) + assert "LinodeMissingPermissionError" in logged + assert "firewall:read_only" in logged + + def test_describe_firewalls_device_fetch_error_yields_none_count(self): + """A devices fetch failure must leave attached_devices_count as None + (undetermined) rather than 0, to avoid a false 'not assigned' FAIL.""" + + class _NoDevicesFw: + id = 5 + label = "no-devices-fw" + status = "enabled" + tags = [] + + @property + def devices(self): + raise Exception("devices API error") + + @property + def rules(self): + r = MagicMock() + r.inbound = [] + r.outbound = [] + r.inbound_policy = "DROP" + r.outbound_policy = "DROP" + return r + + service = _build_service(networking_firewalls_return=[_NoDevicesFw()]) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].attached_devices_count is None + + def test_describe_firewalls_handles_null_rule_fields(self): + """Rule fields returned as explicit null must fall back to defaults + instead of raising a ValidationError that drops the whole firewall.""" + rule = MagicMock() + rule.protocol = None + rule.ports = None + rule.action = None + rule.label = None + addresses = MagicMock() + addresses.ipv4 = None + addresses.ipv6 = None + rule.addresses = addresses + + mock_fws = [_mock_firewall(id=6, label="null-rule-fw", inbound=[rule])] + + service = _build_service(networking_firewalls_return=mock_fws) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + parsed = service.firewalls[0].inbound_rules[0] + assert parsed.protocol == "TCP" + assert parsed.action == "ACCEPT" + assert parsed.ports == "" + assert parsed.addresses_ipv4 == [] + assert parsed.addresses_ipv6 == [] + assert parsed.label == "" + + def test_describe_firewalls_handles_rules_fetch_error(self): + """Firewall is still added even if rules fail to load.""" + + class _BrokenFw: + id = 1 + label = "broken-fw" + status = "enabled" + tags = [] + devices = [] + + @property + def rules(self): + raise Exception("rules API error") + + fw = _BrokenFw() + + service = _build_service(networking_firewalls_return=[fw]) + service._describe_firewalls() + + assert len(service.firewalls) == 1 + assert service.firewalls[0].label == "broken-fw" + assert len(service.firewalls[0].inbound_rules) == 0 diff --git a/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py b/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py new file mode 100644 index 0000000000..72d8a9f454 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_assigned_to_devices/networking_firewall_assigned_to_devices_test.py @@ -0,0 +1,142 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_assigned_to_devices: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 0 + + def test_firewall_assigned(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="assigned-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=2, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "assigned-fw" + + def test_firewall_not_assigned(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="unassigned-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=0, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "unassigned-fw" + + def test_firewall_device_count_undetermined_is_skipped(self): + # attached_devices_count is None when the devices fetch failed; the + # firewall must be skipped rather than reported as a false FAIL. + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=102, + label="undetermined-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=None, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_assigned_to_devices.networking_firewall_assigned_to_devices import ( + networking_firewall_assigned_to_devices, + ) + + check = networking_firewall_assigned_to_devices() + result = check.execute() + + assert len(result) == 0 diff --git a/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py b/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py new file mode 100644 index 0000000000..1543a45656 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_default_inbound_policy_drop/networking_firewall_default_inbound_policy_drop_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_default_inbound_policy_drop: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 0 + + def test_inbound_policy_drop(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="drop-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "drop-fw" + + def test_inbound_policy_accept(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="accept-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="ACCEPT", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_inbound_policy_drop.networking_firewall_default_inbound_policy_drop import ( + networking_firewall_default_inbound_policy_drop, + ) + + check = networking_firewall_default_inbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "accept-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py b/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py new file mode 100644 index 0000000000..5a586d710c --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_default_outbound_policy_drop/networking_firewall_default_outbound_policy_drop_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_default_outbound_policy_drop: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 0 + + def test_outbound_policy_drop(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="drop-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "drop-fw" + + def test_outbound_policy_accept(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="accept-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="ACCEPT", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_default_outbound_policy_drop.networking_firewall_default_outbound_policy_drop import ( + networking_firewall_default_outbound_policy_drop, + ) + + check = networking_firewall_default_outbound_policy_drop() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "accept-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py b/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py new file mode 100644 index 0000000000..9b8272c2f2 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_inbound_rules_configured/networking_firewall_inbound_rules_configured_test.py @@ -0,0 +1,117 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import ( + Firewall, + FirewallRule, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_inbound_rules_configured: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 0 + + def test_inbound_rules_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="empty-inbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "100" + assert result[0].resource_name == "empty-inbound-fw" + + def test_inbound_rules_not_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="non-empty-inbound-fw", + status="enabled", + inbound_rules=[ + FirewallRule( + protocol="TCP", + ports="443", + addresses_ipv4=["0.0.0.0/0"], + addresses_ipv6=[], + action="ACCEPT", + label="allow-https", + ) + ], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_inbound_rules_configured.networking_firewall_inbound_rules_configured import ( + networking_firewall_inbound_rules_configured, + ) + + check = networking_firewall_inbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "101" + assert result[0].resource_name == "non-empty-inbound-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py b/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py new file mode 100644 index 0000000000..658569c940 --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_outbound_rules_configured/networking_firewall_outbound_rules_configured_test.py @@ -0,0 +1,117 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import ( + Firewall, + FirewallRule, +) +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_outbound_rules_configured: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 0 + + def test_outbound_rules_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="empty-outbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "100" + assert result[0].resource_name == "empty-outbound-fw" + + def test_outbound_rules_not_empty(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="non-empty-outbound-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[ + FirewallRule( + protocol="TCP", + ports="443", + addresses_ipv4=["0.0.0.0/0"], + addresses_ipv6=[], + action="ACCEPT", + label="allow-https-egress", + ) + ], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_outbound_rules_configured.networking_firewall_outbound_rules_configured import ( + networking_firewall_outbound_rules_configured, + ) + + check = networking_firewall_outbound_rules_configured() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "101" + assert result[0].resource_name == "non-empty-outbound-fw" diff --git a/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py b/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py new file mode 100644 index 0000000000..632d94ca2e --- /dev/null +++ b/tests/providers/linode/services/networking/networking_firewall_status_enabled/networking_firewall_status_enabled_test.py @@ -0,0 +1,105 @@ +from unittest import mock + +from prowler.providers.linode.services.networking.networking_service import Firewall +from tests.providers.linode.linode_fixtures import set_mocked_linode_provider + + +class Test_networking_firewall_status_enabled: + def test_no_firewalls(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 0 + + def test_firewall_enabled(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=100, + label="enabled-fw", + status="enabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "100" + assert result[0].resource_name == "enabled-fw" + + def test_firewall_disabled(self): + networking_client = mock.MagicMock() + networking_client.firewalls = [ + Firewall( + id=101, + label="disabled-fw", + status="disabled", + inbound_rules=[], + outbound_rules=[], + inbound_policy="DROP", + outbound_policy="DROP", + attached_devices_count=1, + tags=[], + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_linode_provider(), + ), + mock.patch( + "prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled.networking_client", + new=networking_client, + ), + ): + from prowler.providers.linode.services.networking.networking_firewall_status_enabled.networking_firewall_status_enabled import ( + networking_firewall_status_enabled, + ) + + check = networking_firewall_status_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "101" + assert result[0].resource_name == "disabled-fw" diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py new file mode 100644 index 0000000000..6d3e7786cf --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_no_deleted_object_references/entra_conditional_access_policy_no_deleted_object_references_test.py @@ -0,0 +1,466 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessPolicy, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +def _make_policy( + *, + display_name="Test Policy", + state=ConditionalAccessPolicyState.ENABLED, + included_users=None, + excluded_users=None, + included_groups=None, + excluded_groups=None, + included_roles=None, + excluded_roles=None, +): + """Build a ConditionalAccessPolicy with the minimum fields required by the model.""" + policy_id = str(uuid4()) + policy = ConditionalAccessPolicy( + id=policy_id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=[], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_users=included_users or [], + excluded_users=excluded_users or [], + included_groups=included_groups or [], + excluded_groups=excluded_groups or [], + included_roles=included_roles or [], + excluded_roles=excluded_roles or [], + ), + client_app_types=[], + ), + grant_controls=GrantControls( + built_in_controls=[], + operator=GrantControlOperator.OR, + authentication_strength=None, + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode=""), + sign_in_frequency=SignInFrequency( + is_enabled=False, frequency=None, type=None, interval=None + ), + ), + state=state, + ) + return policy_id, policy + + +def _entra_client_mock(): + client = mock.MagicMock() + client.audited_tenant = "audited_tenant" + client.audited_domain = DOMAIN + # Default to clean resolution; individual tests override as needed. + client.unresolved_directory_object_references = set() + client.errored_directory_object_references = set() + return client + + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_conditional_access_policy_no_deleted_object_references." + "entra_conditional_access_policy_no_deleted_object_references.entra_client" +) + + +class Test_entra_conditional_access_policy_no_deleted_object_references: + def test_no_policies(self): + """No Conditional Access policies in tenant: no findings.""" + entra_client = _entra_client_mock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 0 + + def test_sentinel_only_references_pass(self): + """Policy with only sentinel values ('All', 'GuestsOrExternalUsers') passes.""" + entra_client = _entra_client_mock() + policy_id, policy = _make_policy( + display_name="MFA For All", + included_users=["All"], + excluded_users=["GuestsOrExternalUsers"], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + "references no deleted directory objects" in result[0].status_extended + ) + assert result[0].resource_id == policy_id + assert result[0].resource_name == "MFA For All" + + def test_all_references_resolve_pass(self): + """Policy with real identifiers, none in the unresolved set: PASS.""" + entra_client = _entra_client_mock() + live_user = str(uuid4()) + live_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Targeted Policy", + included_users=[live_user], + included_groups=[live_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_deleted_user_in_include_fails(self): + """Policy referencing a deleted user in includeUsers fails with type+side reported.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Require MFA", + included_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 deleted directory object(s)" in result[0].status_extended + assert "users:" in result[0].status_extended + assert deleted_user in result[0].status_extended + assert "(include)" in result[0].status_extended + + def test_deleted_group_in_exclude_fails(self): + """Policy referencing a deleted group in excludeGroups fails with exclude side reported.""" + entra_client = _entra_client_mock() + deleted_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Block Legacy Auth", + included_users=["All"], + excluded_groups=[deleted_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("group", deleted_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "groups:" in result[0].status_extended + assert "(exclude)" in result[0].status_extended + + def test_deleted_role_in_disabled_policy_still_fails(self): + """Disabled policy with a stale role reference still FAILs (per spec).""" + entra_client = _entra_client_mock() + deleted_role = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Legacy Admin Policy", + state=ConditionalAccessPolicyState.DISABLED, + included_roles=[deleted_role], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("role", deleted_role) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "roles:" in result[0].status_extended + assert deleted_role in result[0].status_extended + + def test_orphans_grouped_by_type_across_collections(self): + """A single policy with orphans of every type aggregates them grouped by type.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + deleted_group = str(uuid4()) + deleted_role = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Composite Policy", + included_users=[deleted_user], + excluded_groups=[deleted_group], + included_roles=[deleted_role], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user), + ("group", deleted_group), + ("role", deleted_role), + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "3 deleted directory object(s)" in result[0].status_extended + assert "users:" in result[0].status_extended + assert "groups:" in result[0].status_extended + assert "roles:" in result[0].status_extended + + def test_report_only_policy_failure_notes_mode(self): + """A report-only policy with an orphan FAILs and flags the not-yet-enforced state.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Report Only MFA", + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "report-only mode" in result[0].status_extended + + def test_unverified_reference_is_manual(self): + """A reference that errored (non-404) yields MANUAL, not PASS.""" + entra_client = _entra_client_mock() + errored_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Throttled Lookup Policy", + included_users=["All"], + excluded_groups=[errored_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = set() + entra_client.errored_directory_object_references = { + ("group", errored_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "could not be fully evaluated" in result[0].status_extended + assert errored_group in result[0].status_extended + + def test_orphan_takes_precedence_over_unverified(self): + """A confirmed deletion FAILs even when another reference is unverified.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + errored_group = str(uuid4()) + policy_id, policy = _make_policy( + display_name="Mixed Policy", + included_users=[deleted_user], + excluded_groups=[errored_group], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = {policy_id: policy} + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + entra_client.errored_directory_object_references = { + ("group", errored_group) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "1 deleted directory object(s)" in result[0].status_extended + assert "could not be verified" in result[0].status_extended + + def test_multiple_policies_mixed(self): + """Two policies: one clean, one with an orphan. Distinct PASS/FAIL findings.""" + entra_client = _entra_client_mock() + deleted_user = str(uuid4()) + + clean_id, clean_policy = _make_policy( + display_name="Clean Policy", + included_users=["All"], + ) + dirty_id, dirty_policy = _make_policy( + display_name="Stale Reference Policy", + excluded_users=[deleted_user], + ) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(CHECK_MODULE, new=entra_client), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_no_deleted_object_references.entra_conditional_access_policy_no_deleted_object_references import ( + entra_conditional_access_policy_no_deleted_object_references, + ) + + entra_client.conditional_access_policies = { + clean_id: clean_policy, + dirty_id: dirty_policy, + } + entra_client.unresolved_directory_object_references = { + ("user", deleted_user) + } + + check = entra_conditional_access_policy_no_deleted_object_references() + result = check.execute() + + assert len(result) == 2 + + clean_result = next(r for r in result if r.resource_id == clean_id) + dirty_result = next(r for r in result if r.resource_id == dirty_id) + + assert clean_result.status == "PASS" + assert dirty_result.status == "FAIL" + assert "(exclude)" in dirty_result.status_extended diff --git a/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py b/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py new file mode 100644 index 0000000000..6c8696da55 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_directory_sync_object_takeover_blocked/entra_directory_sync_object_takeover_blocked_test.py @@ -0,0 +1,339 @@ +from unittest import mock + +from prowler.providers.m365.services.entra.entra_service import ( + DirectorySyncSettings, + Organization, +) +from tests.providers.m365.m365_fixtures import set_mocked_m365_provider + +CHECK_MODULE = ( + "prowler.providers.m365.services.entra." + "entra_directory_sync_object_takeover_blocked." + "entra_directory_sync_object_takeover_blocked" +) + + +def _hybrid_org(): + return Organization( + id="org-001", + name="Hybrid Org", + on_premises_sync_enabled=True, + ) + + +def _cloud_only_org(): + return Organization( + id="org-001", + name="Cloud Only Org", + on_premises_sync_enabled=False, + ) + + +class Test_entra_directory_sync_object_takeover_blocked: + def test_both_blocks_enabled(self): + """PASS when both soft-match and hard-match blocks are enabled.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=True, + block_cloud_object_takeover_through_hard_match_enabled=True, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "blocks both soft-match and hard-match" in result[0].status_extended + + def test_soft_match_disabled(self): + """FAIL when soft-match block is disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=True, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "blockSoftMatchEnabled" in result[0].status_extended + + def test_hard_match_disabled(self): + """FAIL when hard-match block is disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=True, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "blockCloudObjectTakeoverThroughHardMatchEnabled" + in result[0].status_extended + ) + + def test_both_blocks_disabled(self): + """FAIL when both blocks are disabled on a hybrid tenant.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="sync-001", + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "blockSoftMatchEnabled" in result[0].status_extended + assert ( + "blockCloudObjectTakeoverThroughHardMatchEnabled" + in result[0].status_extended + ) + + def test_cloud_only_tenant(self): + """PASS when tenant is cloud-only and no sync object is returned.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_cloud_only_tenant_with_sync_object_returned(self): + """PASS for cloud-only tenants even when Graph returns a sync object. + + Microsoft Graph returns an onPremisesSynchronization object (with all + features disabled) for cloud-only tenants. The check must not treat the + disabled flags as a FAIL when on-premises sync is not enabled. + """ + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [ + DirectorySyncSettings( + id="tenant-id", + password_sync_enabled=False, + seamless_sso_enabled=False, + block_soft_match_enabled=False, + block_cloud_object_takeover_through_hard_match_enabled=False, + ) + ] + entra_client.directory_sync_error = None + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_permission_error_hybrid(self): + """MANUAL when permissions are insufficient for a hybrid tenant.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = "Insufficient privileges" + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert "Cannot verify" in result[0].status_extended + assert "Insufficient privileges" in result[0].status_extended + + def test_permission_error_cloud_only(self): + """PASS when settings cannot be read but the tenant is cloud-only.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = "Insufficient privileges" + entra_client.organizations = [_cloud_only_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "cloud-only" in result[0].status_extended + + def test_hybrid_no_settings_returned(self): + """MANUAL when a hybrid tenant returns no directory sync settings.""" + entra_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + f"{CHECK_MODULE}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_directory_sync_object_takeover_blocked.entra_directory_sync_object_takeover_blocked import ( + entra_directory_sync_object_takeover_blocked, + ) + + entra_client.directory_sync_settings = [] + entra_client.directory_sync_error = None + entra_client.organizations = [_hybrid_org()] + + check = entra_directory_sync_object_takeover_blocked() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + "no directory sync settings were returned" in result[0].status_extended + ) diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index 4643ea93cc..ba6247c7bd 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -878,3 +878,189 @@ class Test_Entra_Service: assert merged.password_credentials[0].key_id == "cred-app" assert merged.password_credentials[0].display_name == "app-level-secret" assert merged.password_credentials[0].is_active() + + def test__resolve_identifiers_for_type_flags_only_404(self): + """Only HTTP 404 / Request_ResourceNotFound mark an id as deleted. + + Transient errors (5xx, throttling) and successful resolutions must + never be added to the unresolved set — that is the contract the check + relies on to avoid false positives during Graph outages. + """ + from msgraph.generated.models.o_data_errors.main_error import MainError + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_by_status = "deleted-status-404" + deleted_by_code = "deleted-code-rnf" + transient = "transient-503" + live = "live-user" + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None # status code alone is enough + + error_rnf = ODataError() + error_rnf.response_status_code = None + error_rnf.error = MainError() + error_rnf.error.code = "Request_ResourceNotFound" + + error_503 = ODataError() + error_503.response_status_code = 503 + error_503.error = MainError() + error_503.error.code = "ServiceUnavailable" + + user_builders = { + deleted_by_status: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + deleted_by_code: SimpleNamespace(get=AsyncMock(side_effect=error_rnf)), + transient: SimpleNamespace(get=AsyncMock(side_effect=error_503)), + live: SimpleNamespace(get=AsyncMock(return_value=SimpleNamespace(id=live))), + } + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + users=SimpleNamespace( + by_user_id=MagicMock(side_effect=lambda uid: user_builders[uid]) + ) + ) + + unresolved = set() + errored = set() + asyncio.run( + entra_service._resolve_identifiers_for_type( + "user", set(user_builders), unresolved, errored + ) + ) + + assert unresolved == { + ("user", deleted_by_status), + ("user", deleted_by_code), + } + # The transient 503 must be recorded as errored (unverified), never as + # deleted and never silently dropped. + assert errored == {("user", transient)} + + def test__resolve_identifiers_for_type_role_uses_role_definitions_endpoint(self): + """A deleted role is resolved against the roleDefinitions endpoint.""" + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_role = "deleted-role-id" + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None + + by_role_id = MagicMock( + return_value=SimpleNamespace(get=AsyncMock(side_effect=error_404)) + ) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + role_management=SimpleNamespace( + directory=SimpleNamespace( + role_definitions=SimpleNamespace( + by_unified_role_definition_id=by_role_id + ) + ) + ) + ) + + unresolved = set() + errored = set() + asyncio.run( + entra_service._resolve_identifiers_for_type( + "role", {deleted_role}, unresolved, errored + ) + ) + + assert unresolved == {("role", deleted_role)} + assert errored == set() + by_role_id.assert_called_once_with(deleted_role) + + def test__resolve_directory_object_references_skips_sentinels_and_dedups(self): + """End-to-end resolver: sentinels are never queried, ids are deduped + across policies, and only deleted ids land in the unresolved set.""" + from msgraph.generated.models.o_data_errors.o_data_error import ODataError + + deleted_user = "deleted-user-id" + live_user = "live-user-id" + deleted_group = "deleted-group-id" + errored_group = "errored-group-id" + + def _user_conditions(**kwargs): + base = { + "included_users": [], + "excluded_users": [], + "included_groups": [], + "excluded_groups": [], + "included_roles": [], + "excluded_roles": [], + } + base.update(kwargs) + return SimpleNamespace(**base) + + def _policy(user_conditions): + return SimpleNamespace( + conditions=SimpleNamespace(user_conditions=user_conditions) + ) + + policies = { + "policy-a": _policy( + _user_conditions( + included_users=["All", deleted_user, live_user], + excluded_groups=[deleted_group, errored_group], + ) + ), + # Same deleted_user referenced again — must be resolved only once. + "policy-b": _policy( + _user_conditions( + included_users=[deleted_user], + excluded_users=["GuestsOrExternalUsers"], + ) + ), + # Policy without user conditions must be skipped without error. + "policy-c": SimpleNamespace( + conditions=SimpleNamespace(user_conditions=None) + ), + } + + error_404 = ODataError() + error_404.response_status_code = 404 + error_404.error = None + + error_503 = ODataError() + error_503.response_status_code = 503 + error_503.error = None + + user_builders = { + deleted_user: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + live_user: SimpleNamespace( + get=AsyncMock(return_value=SimpleNamespace(id=live_user)) + ), + } + group_builders = { + deleted_group: SimpleNamespace(get=AsyncMock(side_effect=error_404)), + errored_group: SimpleNamespace(get=AsyncMock(side_effect=error_503)), + } + by_user_id = MagicMock(side_effect=lambda uid: user_builders[uid]) + by_group_id = MagicMock(side_effect=lambda gid: group_builders[gid]) + + entra_service = Entra.__new__(Entra) + entra_service.client = SimpleNamespace( + users=SimpleNamespace(by_user_id=by_user_id), + groups=SimpleNamespace(by_group_id=by_group_id), + ) + + unresolved, errored = asyncio.run( + entra_service._resolve_directory_object_references(policies) + ) + + assert unresolved == { + ("user", deleted_user), + ("group", deleted_group), + } + # The 503 group is unverified, not deleted — it lands in errored. + assert errored == {("group", errored_group)} + # Sentinels are filtered before any Graph call; only the two real user + # ids are queried, and the deduped deleted_user is queried exactly once. + queried_users = {call.args[0] for call in by_user_id.call_args_list} + assert queried_users == {deleted_user, live_user} + assert user_builders[deleted_user].get.await_count == 1 diff --git a/tests/providers/openstack/openstack_provider_test.py b/tests/providers/openstack/openstack_provider_test.py index 654d109134..b36fcd97a5 100644 --- a/tests/providers/openstack/openstack_provider_test.py +++ b/tests/providers/openstack/openstack_provider_test.py @@ -631,8 +631,7 @@ class TestOpenstackProviderCloudsYaml: def test_clouds_yaml_explicit_file_path(self, tmp_path): """Test loading clouds.yaml from an explicit file path.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -644,8 +643,7 @@ clouds: project_domain_name: YamlProjectDomain region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -681,8 +679,7 @@ clouds: def test_clouds_yaml_with_explicit_cloud_name(self, tmp_path): """Test loading clouds.yaml with an explicit cloud name.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: default-cloud: auth: @@ -692,8 +689,7 @@ clouds: project_id: default-project-id region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -719,8 +715,7 @@ clouds: def test_clouds_yaml_file_without_cloud_name(self, tmp_path): """Test error when clouds.yaml file is provided without cloud name.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -729,8 +724,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError) as excinfo: OpenstackProvider(clouds_yaml_file=str(clouds_yaml)) @@ -750,8 +744,7 @@ clouds: def test_clouds_yaml_cloud_not_found(self, tmp_path): """Test error when specified cloud is not in clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: existing-cloud: auth: @@ -760,8 +753,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackCloudNotFoundError) as excinfo: OpenstackProvider( @@ -774,8 +766,7 @@ clouds: def test_clouds_yaml_missing_required_fields(self, tmp_path): """Test error when clouds.yaml is missing required fields.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: incomplete-cloud: auth: @@ -783,8 +774,7 @@ clouds: username: test-user # Missing password and other required fields region_name: RegionOne -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError) as excinfo: OpenstackProvider( @@ -798,16 +788,14 @@ clouds: def test_clouds_yaml_malformed_yaml(self, tmp_path): """Test error when clouds.yaml is malformed.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: malformed-cloud: auth: auth_url: https://openstack.example.com:5000/v3 username: test-user - invalid: yaml: structure -""" - ) +""") with pytest.raises(OpenStackInvalidConfigError): OpenstackProvider( @@ -818,8 +806,7 @@ clouds: def test_clouds_yaml_with_project_name(self, tmp_path): """Test clouds.yaml using project_name instead of project_id.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -831,8 +818,7 @@ clouds: project_domain_name: Default region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -863,8 +849,7 @@ clouds: monkeypatch.setenv("OS_REGION_NAME", "EnvRegion") clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -874,8 +859,7 @@ clouds: project_id: yaml-project-id region_name: YamlRegion identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -902,8 +886,7 @@ clouds: def test_test_connection_with_clouds_yaml(self, tmp_path): """Test static test_connection method with clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -913,8 +896,7 @@ clouds: project_id: test-project-id region_name: RegionOne identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -950,8 +932,7 @@ clouds: def test_test_connection_clouds_yaml_cloud_not_found(self, tmp_path): """Test test_connection error when cloud is not in clouds.yaml.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: existing-cloud: auth: @@ -960,8 +941,7 @@ clouds: password: test-password project_id: test-project-id region_name: RegionOne -""" - ) +""") connection_result = OpenstackProvider.test_connection( clouds_yaml_file=str(clouds_yaml), @@ -1139,8 +1119,7 @@ clouds: def test_clouds_yaml_file_with_regions_list(self, tmp_path): """Test loading clouds.yaml file with regions list.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1152,8 +1131,7 @@ clouds: - RegionOne - RegionTwo identity_api_version: 3 -""" - ) +""") mock_connection = MagicMock() mock_connection.authorize.return_value = None @@ -1177,8 +1155,7 @@ clouds: def test_clouds_yaml_file_with_both_regions_raises_error(self, tmp_path): """Test that clouds.yaml file with both region_name and regions raises error.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1191,8 +1168,7 @@ clouds: - RegionOne - RegionTwo identity_api_version: 3 -""" - ) +""") with pytest.raises(OpenStackAmbiguousRegionError): OpenstackProvider( @@ -1203,8 +1179,7 @@ clouds: def test_clouds_yaml_file_with_no_region_raises_error(self, tmp_path): """Test that clouds.yaml file with neither region_name nor regions raises error.""" clouds_yaml = tmp_path / "clouds.yaml" - clouds_yaml.write_text( - """ + clouds_yaml.write_text(""" clouds: test-cloud: auth: @@ -1213,8 +1188,7 @@ clouds: password: test-password project_id: test-project-id identity_api_version: 3 -""" - ) +""") with pytest.raises(OpenStackNoRegionError): OpenstackProvider( diff --git a/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py b/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py new file mode 100644 index 0000000000..5b974c893c --- /dev/null +++ b/tests/providers/oraclecloud/services/identity/identity_storage_service_level_admins_scoped/identity_storage_service_level_admins_scoped_test.py @@ -0,0 +1,326 @@ +from datetime import datetime +from unittest import mock + +import pytest + +from prowler.lib.check.models import Check_Report_OCI +from prowler.providers.oraclecloud.services.identity.identity_service import Policy +from tests.providers.oraclecloud.oci_fixtures import ( + OCI_COMPARTMENT_ID, + OCI_REGION, + OCI_TENANCY_ID, + set_mocked_oraclecloud_provider, +) + +CHECK_PATH = "prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped" + + +def _policy( + name: str, statements: list[str], lifecycle_state: str = "ACTIVE" +) -> Policy: + return Policy( + id=f"ocid1.policy.oc1..{name.lower().replace(' ', '-')}", + name=name, + description="Test policy", + compartment_id=OCI_COMPARTMENT_ID, + statements=statements, + time_created=datetime.now(), + lifecycle_state=lifecycle_state, + region=OCI_REGION, + ) + + +def _identity_client(policies: list[Policy]) -> mock.MagicMock: + identity_client = mock.MagicMock() + identity_client.policies = policies + identity_client.audited_tenancy = OCI_TENANCY_ID + identity_client.audited_regions = [mock.MagicMock(key=OCI_REGION)] + return identity_client + + +def _run_check(policies: list[Policy]) -> list[Check_Report_OCI]: + identity_client = _identity_client(policies) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_oraclecloud_provider(), + ), + mock.patch(f"{CHECK_PATH}.identity_client", new=identity_client), + ): + from prowler.providers.oraclecloud.services.identity.identity_storage_service_level_admins_scoped.identity_storage_service_level_admins_scoped import ( + identity_storage_service_level_admins_scoped, + ) + + return identity_storage_service_level_admins_scoped().execute() + + +class Test_identity_storage_service_level_admins_scoped: + def test_no_policies_passes_with_tenancy_finding(self): + result = _run_check([]) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == OCI_TENANCY_ID + assert result[0].resource_name == "Tenancy" + assert ( + result[0].status_extended + == "No active storage service-level administrator policies grant manage permissions without excluding delete permissions." + ) + + def test_manage_volumes_without_delete_exclusion_fails(self): + result = _run_check( + [ + _policy( + "Volume Admins", + ["Allow group VolumeUsers to manage volumes in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "Volume Admins" + assert "VOLUME_DELETE" in result[0].status_extended + assert ( + "Allow group VolumeUsers to manage volumes in tenancy" + in result[0].status_extended + ) + + def test_manage_volumes_with_delete_exclusion_passes(self): + result = _run_check( + [ + _policy( + "Volume Admins", + [ + "Allow group VolumeUsers to manage volumes in tenancy where request.permission!='VOLUME_DELETE'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Policy 'Volume Admins' excludes required storage delete permissions from storage manage statements." + ) + + def test_delete_exclusion_parser_is_case_and_whitespace_insensitive(self): + result = _run_check( + [ + _policy( + "Volume Admins", + [ + " allow group VolumeUsers TO manage volumes in tenancy WHERE request.permission != 'volume_delete' " + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_generic_where_clause_does_not_pass(self): + result = _run_check( + [ + _policy( + "Bucket Admins", + [ + "Allow group BucketUsers to manage buckets in tenancy where request.region='iad'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "BUCKET_DELETE" in result[0].status_extended + assert "request.region='iad'" in result[0].status_extended + + @pytest.mark.parametrize( + "statement", + [ + "Allow group BucketUsers to manage buckets in tenancy where ANY {request.permission!='BUCKET_DELETE', request.region='iad'}", + "Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' OR request.region='iad'", + ], + ) + def test_disjunctive_delete_exclusion_does_not_pass(self, statement): + result = _run_check([_policy("Bucket Admins", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "BUCKET_DELETE" in result[0].status_extended + + def test_quoted_literals_do_not_make_delete_exclusion_disjunctive(self): + result = _run_check( + [ + _policy( + "Bucket Admins", + [ + "Allow group BucketUsers to manage buckets in tenancy where request.permission!='BUCKET_DELETE' and target.tag.namespace='any-tag'" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + @pytest.mark.parametrize( + "resource,permission", + [ + ("file-systems", "FILE_SYSTEM_DELETE"), + ("mount-targets", "MOUNT_TARGET_DELETE"), + ("export-sets", "EXPORT_SET_DELETE"), + ("volumes", "VOLUME_DELETE"), + ("volume-backups", "VOLUME_BACKUP_DELETE"), + ("objects", "OBJECT_DELETE"), + ("buckets", "BUCKET_DELETE"), + ], + ) + def test_storage_resources_require_matching_delete_exclusion( + self, resource, permission + ): + fail_result = _run_check( + [ + _policy( + "Storage Admins", + [f"Allow group StorageUsers to manage {resource} in tenancy"], + ) + ] + ) + pass_result = _run_check( + [ + _policy( + "Storage Admins", + [ + f"Allow group StorageUsers to manage {resource} in tenancy where request.permission != '{permission}'" + ], + ) + ] + ) + + assert len(fail_result) == 1 + assert fail_result[0].status == "FAIL" + assert permission in fail_result[0].status_extended + assert len(pass_result) == 1 + assert pass_result[0].status == "PASS" + + def test_file_family_fails_until_all_delete_permissions_are_excluded(self): + partial_result = _run_check( + [ + _policy( + "File Admins", + [ + "Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE'}" + ], + ) + ] + ) + complete_result = _run_check( + [ + _policy( + "File Admins", + [ + "Allow group FileUsers to manage file-family in tenancy where ALL {request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE'}" + ], + ) + ] + ) + + assert len(partial_result) == 1 + assert partial_result[0].status == "FAIL" + assert "EXPORT_SET_DELETE" in partial_result[0].status_extended + assert len(complete_result) == 1 + assert complete_result[0].status == "PASS" + + @pytest.mark.parametrize( + "family,missing_permission,statement", + [ + ( + "volume-family", + "VOLUME_BACKUP_DELETE", + "Allow group VolumeUsers to manage volume-family in tenancy where request.permission!='VOLUME_DELETE'", + ), + ( + "object-family", + "BUCKET_DELETE", + "Allow group BucketUsers to manage object-family in tenancy where request.permission!='OBJECT_DELETE'", + ), + ( + "all-resources", + "BUCKET_DELETE", + "Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE'}", + ), + ], + ) + def test_families_and_all_resources_fail_unless_all_delete_permissions_are_excluded( + self, family, missing_permission, statement + ): + result = _run_check([_policy("Storage Admins", [statement])]) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert family in result[0].status_extended + assert missing_permission in result[0].status_extended + + def test_all_resources_passes_when_all_storage_delete_permissions_are_excluded( + self, + ): + result = _run_check( + [ + _policy( + "Storage Admins", + [ + "Allow group StorageUsers to manage all-resources in tenancy where ALL {request.permission!='VOLUME_DELETE', request.permission!='VOLUME_BACKUP_DELETE', request.permission!='FILE_SYSTEM_DELETE', request.permission!='MOUNT_TARGET_DELETE', request.permission!='EXPORT_SET_DELETE', request.permission!='OBJECT_DELETE', request.permission!='BUCKET_DELETE'}" + ], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + + def test_inactive_policies_are_ignored(self): + result = _run_check( + [ + _policy( + "Inactive Volume Admins", + ["Allow group VolumeUsers to manage volumes in tenancy"], + lifecycle_state="INACTIVE", + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" + + def test_tenant_admin_policy_is_ignored(self): + result = _run_check( + [ + _policy( + "Tenant Admin Policy", + ["Allow group Administrators to manage all-resources in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" + + def test_policies_without_storage_manage_statements_are_ignored(self): + result = _run_check( + [ + _policy( + "Network Admins", + ["Allow group NetworkUsers to manage vcns in tenancy"], + ) + ] + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "Tenancy" diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py new file mode 100644 index 0000000000..2d1e6b7875 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_access_key_expiration/objectstorage_access_key_expiration_test.py @@ -0,0 +1,150 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_access_key_expiration: + def test_no_access_keys(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 0 + + def test_access_key_with_expiration(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-123", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has an expiration date set" in result[0].status_extended + assert result[0].resource_id == "key-123" + assert result[0].resource_name == "my-key" + assert result[0].location == "eu01" + + def test_access_key_no_expiration_none(self): + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-456", + display_name="never-expiring-key", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended + assert result[0].resource_id == "key-456" + + def test_access_key_no_expiration_sentinel(self): + """Year-0001 date is the SDK sentinel for 'never expires'.""" + objectstorage_client = mock.MagicMock + objectstorage_client.access_keys = [ + AccessKey( + key_id="key-789", + display_name="sentinel-key", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_access_key_expiration.objectstorage_access_key_expiration import ( + objectstorage_access_key_expiration, + ) + + check = objectstorage_access_key_expiration() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "no expiration date" in result[0].status_extended diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py new file mode 100644 index 0000000000..a802dac78f --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_object_lock_enabled/objectstorage_bucket_object_lock_enabled_test.py @@ -0,0 +1,111 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_object_lock_enabled: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 0 + + def test_bucket_object_lock_enabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "has S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].resource_name == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_object_lock_disabled(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_object_lock_enabled.objectstorage_bucket_object_lock_enabled import ( + objectstorage_bucket_object_lock_enabled, + ) + + check = objectstorage_bucket_object_lock_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "does not have S3 Object Lock enabled" in result[0].status_extended + assert result[0].resource_id == "my-bucket" diff --git a/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py new file mode 100644 index 0000000000..59fdf73370 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/objectstorage_bucket_retention_policy/objectstorage_bucket_retention_policy_test.py @@ -0,0 +1,153 @@ +from unittest import mock + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + Bucket, +) +from tests.providers.stackit.stackit_fixtures import ( + STACKIT_PROJECT_ID, + set_mocked_stackit_provider, +) + + +class Test_objectstorage_bucket_retention_policy: + def test_no_buckets(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 0 + + def test_bucket_with_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=30, + retention_mode="COMPLIANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert "30 day(s)" in result[0].status_extended + assert "COMPLIANCE" in result[0].status_extended + assert result[0].resource_id == "my-bucket" + assert result[0].location == "eu01" + + def test_bucket_without_retention_policy(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=False, + retention_days=None, + retention_mode=None, + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + "does not have a default retention policy" in result[0].status_extended + ) + assert result[0].resource_id == "my-bucket" + + def test_bucket_retention_zero_days(self): + objectstorage_client = mock.MagicMock + objectstorage_client.buckets = [ + Bucket( + name="my-bucket", + region="eu01", + project_id=STACKIT_PROJECT_ID, + object_lock_enabled=True, + retention_days=0, + retention_mode="GOVERNANCE", + ) + ] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_stackit_provider(), + ), + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_service.ObjectStorageService", + new=objectstorage_client, + ) as service_client, + mock.patch( + "prowler.providers.stackit.services.objectstorage.objectstorage_client.objectstorage_client", + new=service_client, + ), + ): + from prowler.providers.stackit.services.objectstorage.objectstorage_bucket_retention_policy.objectstorage_bucket_retention_policy import ( + objectstorage_bucket_retention_policy, + ) + + check = objectstorage_bucket_retention_policy() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" diff --git a/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py new file mode 100644 index 0000000000..59f6559a54 --- /dev/null +++ b/tests/providers/stackit/services/objectstorage/stackit_objectstorage_service_test.py @@ -0,0 +1,645 @@ +from types import SimpleNamespace +from unittest import mock + +import pytest + +from prowler.providers.stackit.services.objectstorage.objectstorage_service import ( + AccessKey, + ObjectStorageService, +) +from tests.providers.stackit.stackit_fixtures import STACKIT_PROJECT_ID + + +class TestObjectStorageService: + def test_list_buckets_keeps_bucket_when_retention_not_configured(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found_error = Exception("not found") + not_found_error.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = not_found_error + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "my-bucket" + assert service.buckets[0].object_lock_enabled is True + assert service.buckets[0].retention_days is None + assert service.buckets[0].retention_mode is None + + def test_list_buckets_propagates_unexpected_retention_api_errors(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + api_error = Exception("service unavailable") + api_error.status = 503 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[ + SimpleNamespace( + name="my-bucket", + object_lock_enabled=True, + ) + ] + ) + client.get_default_retention.side_effect = api_error + + with pytest.raises(Exception, match="service unavailable"): + service._list_buckets(client, "eu01") + + assert service.buckets == [] + service.provider.handle_api_error.assert_called_once_with(api_error) + + def test_init_creates_service_with_no_regions(self): + provider = mock.MagicMock() + provider.identity.project_id = STACKIT_PROJECT_ID + provider.generate_regional_clients.return_value = {} + + service = ObjectStorageService(provider) + + assert service.project_id == STACKIT_PROJECT_ID + assert service.buckets == [] + assert service.access_keys == [] + provider.generate_regional_clients.assert_called_once_with("objectstorage") + + def test_fetch_all_regions_skips_404_region(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + not_found = Exception("not found") + not_found.status = 404 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=not_found): + service._fetch_all_regions() + + assert service.buckets == [] + + def test_fetch_all_regions_reraises_non_404_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + server_error = Exception("internal server error") + server_error.status = 500 + service.regional_clients = {"eu01": mock.MagicMock()} + + with mock.patch.object(service, "_list_buckets", side_effect=server_error): + with pytest.raises(Exception, match="internal server error"): + service._fetch_all_regions() + + def test_list_buckets_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = { + "buckets": [ + SimpleNamespace(name="dict-response-bucket", object_lock_enabled=True) + ] + } + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-response-bucket" + + def test_list_buckets_with_dict_bucket_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + not_found = Exception("not found") + not_found.status = 404 + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace( + buckets=[{"name": "dict-bucket", "objectLockEnabled": True}] + ) + client.get_default_retention.side_effect = not_found + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 1 + assert service.buckets[0].name == "dict-bucket" + assert service.buckets[0].object_lock_enabled is True + + def test_list_buckets_skips_unknown_bucket_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[42]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_get_default_retention_with_dict_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + + client = mock.MagicMock() + client.get_default_retention.return_value = {"days": 14, "mode": "GOVERNANCE"} + + days, mode = service._get_default_retention(client, "eu01", "my-bucket") + + assert days == 14 + assert mode == "GOVERNANCE" + + def test_list_access_keys_with_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001", display_name="main-group")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="my-key", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ) + + service._list_access_keys(client, "eu01") + + client.list_credentials_groups.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01" + ) + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-001" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-001" + assert service.access_keys[0].display_name == "my-key" + assert service.access_keys[0].region == "eu01" + assert service.access_keys[0].expires == "2027-01-01T00:00:00+00:00" + assert service.access_keys[0].credentials_group_id == "cg-001" + assert service.access_keys[0].credentials_group_name == "main-group" + + def test_list_access_keys_with_credentials_group_id_object_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace( + credentials_group_id="cg-sdk", + display_name="sdk-group", + ) + ] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-sdk" + ) + + def test_list_access_keys_collects_keys_from_multiple_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + SimpleNamespace(id="cg-001", display_name="group-one"), + SimpleNamespace(id="cg-002", display_name="group-two"), + ] + ) + client.list_access_keys.side_effect = [ + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-001", + display_name="key-one", + expires="2027-01-01T00:00:00+00:00", + ) + ] + ), + SimpleNamespace( + access_keys=[ + SimpleNamespace( + key_id="key-002", + display_name="key-two", + expires=None, + ) + ] + ), + ] + + service._list_access_keys(client, "eu01") + + assert client.list_access_keys.call_args_list == [ + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-001", + ), + mock.call( + project_id=STACKIT_PROJECT_ID, + region="eu01", + credentials_group="cg-002", + ), + ] + assert [key.key_id for key in service.access_keys] == ["key-001", "key-002"] + assert service.access_keys[1].expires is None + assert service.access_keys[1].has_expiration() is False + assert [key.credentials_group_id for key in service.access_keys] == [ + "cg-001", + "cg-002", + ] + + def test_list_access_keys_with_dict_api_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = { + "credentialsGroups": [{"id": "cg-dict", "displayName": "dict-group"}] + } + client.list_access_keys.return_value = { + "accessKeys": [ + {"keyId": "key-dict", "displayName": "dict-key", "expires": None} + ] + } + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-dict" + assert service.access_keys[0].display_name == "dict-key" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + assert service.access_keys[0].credentials_group_id == "cg-dict" + assert service.access_keys[0].credentials_group_name == "dict-group" + + def test_list_access_keys_with_raw_json_response_and_null_expires(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + + def json(self): + return { + "accessKeys": [ + { + "keyId": "key-raw", + "displayName": "raw-key", + "expires": None, + } + ] + } + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-raw")] + ) + ) + self.list_access_keys = mock.MagicMock() + self.raw_call = None + + def list_access_keys_without_preload_content(self, **kwargs): + self.raw_call = kwargs + return RawResponse() + + client = FakeClient() + + service._list_access_keys(client, "eu01") + + assert client.raw_call == { + "project_id": STACKIT_PROJECT_ID, + "region": "eu01", + "credentials_group": "cg-raw", + } + client.list_access_keys.assert_not_called() + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-raw" + assert service.access_keys[0].expires is None + assert service.access_keys[0].has_expiration() is False + + def test_list_access_keys_with_raw_data_response(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 200 + data = b'{"accessKeys":[{"keyId":"key-data","displayName":"data-key"}]}' + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-data")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + service._list_access_keys(FakeClient(), "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-data" + assert service.access_keys[0].display_name == "data-key" + + def test_list_access_keys_raw_response_propagates_non_success_status(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class RawResponse: + status = 503 + + class FakeClient: + def __init__(self): + self.list_credentials_groups = mock.MagicMock( + return_value=SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-error")] + ) + ) + + def list_access_keys_without_preload_content(self, **kwargs): + return RawResponse() + + with pytest.raises(Exception, match="status 503") as error: + service._list_access_keys(FakeClient(), "eu01") + + assert error.value.status == 503 + service.provider.handle_api_error.assert_called_once_with(error.value) + + def test_list_access_keys_with_dict_key_data(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[{"id": "cg-456", "displayName": "group-456"}] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[ + { + "keyId": "key-456", + "displayName": "my-dict-key", + "expires": "2028-06-01T00:00:00+00:00", + } + ] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-456" + assert service.access_keys[0].display_name == "my-dict-key" + assert service.access_keys[0].credentials_group_id == "cg-456" + + def test_list_access_keys_skips_unknown_type(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[42]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_keys(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-empty")] + ) + client.list_access_keys.return_value = SimpleNamespace(access_keys=[]) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + def test_list_access_keys_no_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + client.list_access_keys.assert_not_called() + + def test_list_access_keys_skips_malformed_credentials_groups(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[ + 42, + {}, + SimpleNamespace(id="cg-valid", display_name="valid-group"), + ] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[SimpleNamespace(key_id="key-valid")] + ) + + service._list_access_keys(client, "eu01") + + client.list_access_keys.assert_called_once_with( + project_id=STACKIT_PROJECT_ID, region="eu01", credentials_group="cg-valid" + ) + assert len(service.access_keys) == 1 + assert service.access_keys[0].key_id == "key-valid" + + def test_fetch_all_regions_calls_both_list_methods(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + service.access_keys = [] + + service.regional_clients = {"eu01": mock.MagicMock()} + + with ( + mock.patch.object(service, "_list_buckets") as mock_buckets, + mock.patch.object(service, "_list_access_keys") as mock_keys, + ): + service._fetch_all_regions() + + mock_buckets.assert_called_once() + mock_keys.assert_called_once() + + def test_list_buckets_handles_bucket_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.buckets = [] + + class BrokenBucket: + @property + def name(self): + raise RuntimeError("broken bucket attribute") + + client = mock.MagicMock() + client.list_buckets.return_value = SimpleNamespace(buckets=[BrokenBucket()]) + + service._list_buckets(client, "eu01") + + assert len(service.buckets) == 0 + + def test_list_access_keys_handles_key_processing_error(self): + service = ObjectStorageService.__new__(ObjectStorageService) + service.provider = mock.MagicMock() + service.project_id = STACKIT_PROJECT_ID + service.access_keys = [] + + class BrokenKey: + @property + def key_id(self): + raise RuntimeError("broken key attribute") + + client = mock.MagicMock() + client.list_credentials_groups.return_value = SimpleNamespace( + credentials_groups=[SimpleNamespace(id="cg-001")] + ) + client.list_access_keys.return_value = SimpleNamespace( + access_keys=[BrokenKey()] + ) + + service._list_access_keys(client, "eu01") + + assert len(service.access_keys) == 0 + + +class TestAccessKeyModel: + def test_has_expiration_with_invalid_date_string(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="not-a-valid-date", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.has_expiration() is False + + def test_expires_within_days_when_no_expiration(self): + key = AccessKey( + key_id="k", + display_name="k", + expires=None, + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires is None + assert key.has_expiration() is False + assert key.expires_within_days(90) is False + + def test_expires_within_days_when_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-15T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_when_not_expiring_soon(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2030-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(30) is False + + def test_expires_within_days_with_naive_datetime(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="2026-06-10T00:00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is True + + def test_expires_within_days_with_sentinel_key(self): + key = AccessKey( + key_id="k", + display_name="k", + expires="0001-01-01T00:00:00+00:00", + region="eu01", + project_id=STACKIT_PROJECT_ID, + ) + assert key.expires_within_days(90) is False diff --git a/tests/providers/stackit/stackit_provider_test.py b/tests/providers/stackit/stackit_provider_test.py index 8bcbe17f84..13bc7f4698 100644 --- a/tests/providers/stackit/stackit_provider_test.py +++ b/tests/providers/stackit/stackit_provider_test.py @@ -411,3 +411,68 @@ class Test_StackitProvider_Handle_API_Error: with pytest.raises(RuntimeError) as excinfo: StackitProvider.handle_api_error(original) assert excinfo.value is original + + +class TestGenerateRegionalClients: + """Tests for StackitProvider.generate_regional_clients.""" + + def _make_provider(self): + provider = object.__new__(StackitProvider) + provider._service_account_key_path = "/tmp/sa-key.json" + provider._service_account_key = None + provider._audited_regions = None + return provider + + def _fake_classes(self): + class FakeConfig: + pass + + class FakeIaasClient: + def __init__(self, config): + pass + + class FakeObjStorageClient: + def __init__(self, config): + pass + + return FakeConfig, FakeIaasClient, FakeObjStorageClient + + def test_objectstorage_service_uses_objectstorage_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("objectstorage") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeObjStorageClient) + + def test_iaas_service_uses_iaas_api_class(self, monkeypatch): + FakeConfig, FakeIaasClient, FakeObjStorageClient = self._fake_classes() + + monkeypatch.setattr( + StackitProvider, + "_SERVICE_API_CLASS", + {"iaas": FakeIaasClient, "objectstorage": FakeObjStorageClient}, + ) + provider = self._make_provider() + monkeypatch.setattr( + provider, "get_available_service_regions", lambda _s, _r: ["eu01"] + ) + with patch.object( + StackitProvider, "_build_sdk_configuration", return_value=FakeConfig() + ): + clients = provider.generate_regional_clients("iaas") + + assert "eu01" in clients + assert isinstance(clients["eu01"], FakeIaasClient) diff --git a/ui/.pre-commit-config.yaml b/ui/.pre-commit-config.yaml index eab333ceea..3bc0c3b5e6 100644 --- a/ui/.pre-commit-config.yaml +++ b/ui/.pre-commit-config.yaml @@ -1,5 +1,10 @@ orphan: true +# Hooks run on commit only by default; +# NOTE: default_stages does NOT override a hook's manifest stages, so fixers shipping pre-push in their +# manifest need an explicit stages: ["pre-commit"] below to stay off push. +default_stages: [pre-commit] + repos: - repo: local hooks: diff --git a/ui/.prettierignore b/ui/.prettierignore index 5377d96f2d..8b679ff005 100644 --- a/ui/.prettierignore +++ b/ui/.prettierignore @@ -8,6 +8,7 @@ build/ coverage/ dist/ esm/ +CHANGELOG.md # Generated files next-env.d.ts diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 38af45860f..7c06cc56b1 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -14,40 +14,46 @@ > - [`playwright`](../skills/playwright/SKILL.md) - Page Object Model, selectors > - [`vitest`](../skills/vitest/SKILL.md) - Unit testing with React Testing Library > - [`tdd`](../skills/tdd/SKILL.md) - TDD workflow (MANDATORY for UI tasks) +> - [`prowler-tour`](../skills/prowler-tour/SKILL.md) - Keep product-tour definitions aligned with the UI ## Auto-invoke Skills When performing these actions, ALWAYS invoke the corresponding skill FIRST: -| Action | Skill | -| -------------------------------------------------------------- | ------------------- | -| Add changelog entry for a PR or feature | `prowler-changelog` | -| App Router / Server Actions | `nextjs-16` | -| Building AI chat features | `ai-sdk-5` | -| Committing changes | `prowler-commit` | -| Create PR that requires changelog entry | `prowler-changelog` | -| Creating Zod schemas | `zod-4` | -| Creating a git commit | `prowler-commit` | -| Creating/modifying Prowler UI components | `prowler-ui` | -| Fixing bug | `tdd` | -| Implementing feature | `tdd` | -| Modifying component | `tdd` | -| Refactoring code | `tdd` | -| Review changelog format and conventions | `prowler-changelog` | -| Testing hooks or utilities | `vitest` | -| Update CHANGELOG.md in any component | `prowler-changelog` | -| Using Zustand stores | `zustand-5` | -| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | -| Working on task | `tdd` | -| Working with Prowler UI test helpers/pages | `prowler-test-ui` | -| Working with Tailwind classes | `tailwind-4` | -| Writing Playwright E2E tests | `playwright` | -| Writing Prowler UI E2E tests | `prowler-test-ui` | -| Writing React component tests | `vitest` | -| Writing React components | `react-19` | -| Writing TypeScript types/interfaces | `typescript` | -| Writing Vitest tests | `vitest` | -| Writing unit tests for UI | `vitest` | +| Action | Skill | +| ----------------------------------------------------------------- | ------------------- | +| Add changelog entry for a PR or feature | `prowler-changelog` | +| Adding, updating, or removing a tour definition (\*.tour.ts) | `prowler-tour` | +| App Router / Server Actions | `nextjs-16` | +| Building AI chat features | `ai-sdk-5` | +| Changing button labels or section headings on a tour-covered page | `prowler-tour` | +| Committing changes | `prowler-commit` | +| Create PR that requires changelog entry | `prowler-changelog` | +| Creating Zod schemas | `zod-4` | +| Creating a git commit | `prowler-commit` | +| Creating/modifying Prowler UI components | `prowler-ui` | +| Editing a UI file containing data-tour-id attributes | `prowler-tour` | +| Fixing bug | `tdd` | +| Implementing feature | `tdd` | +| Modifying component | `tdd` | +| Refactoring code | `tdd` | +| Renaming or removing a data-tour-id attribute value | `prowler-tour` | +| Restructuring routes or layouts covered by a tour | `prowler-tour` | +| Review changelog format and conventions | `prowler-changelog` | +| Testing hooks or utilities | `vitest` | +| Update CHANGELOG.md in any component | `prowler-changelog` | +| Using Zustand stores | `zustand-5` | +| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` | +| Working on task | `tdd` | +| Working with Prowler UI test helpers/pages | `prowler-test-ui` | +| Working with Tailwind classes | `tailwind-4` | +| Writing Playwright E2E tests | `playwright` | +| Writing Prowler UI E2E tests | `prowler-test-ui` | +| Writing React component tests | `vitest` | +| Writing React components | `react-19` | +| Writing TypeScript types/interfaces | `typescript` | +| Writing Vitest tests | `vitest` | +| Writing unit tests for UI | `vitest` | --- diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 49e6ddf9f0..97f0dc9830 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,15 +2,57 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.30.0] (Prowler UNRELEASED) +## [1.31.0] (Prowler UNRELEASED) ### 🚀 Added +- Controlled `402` and `403` Server Action error messages for alert seed and mutation flows [(#11629)](https://github.com/prowler-cloud/prowler/pull/11629) + +### 🐞 Fixed + +- Attack Paths now shows distinct messages while a scan is queued, running, or building its graph — plus a separate "couldn't load scans" error — instead of always showing "No scans available" [(#11512)](https://github.com/prowler-cloud/prowler/pull/11512) +- Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) +- Handle rename DORA to DORA_2022_2554 to follow the naming _ in compliance frameworks [(#11551)](https://github.com/prowler-cloud/prowler/pull/11551) + +### 🔐 Security + +- Bump vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages (`libcrypto3` and `libssl3`) [(#11581)](https://github.com/prowler-cloud/prowler/pull/11581) +- Bump transitive `dompurify` from 3.4.2 to 3.4.10, patching XSS sanitization bypass advisories [(#11636)](https://github.com/prowler-cloud/prowler/pull/11636) + +--- + +## [1.30.1] (Prowler v5.30.1) + +### 🐞 Fixed + +- Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542) +- Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) + +### 🔄 Changed + +- Public SaaS config (Sentry, Google Tag Manager, API base/docs URL) now resolves at container runtime instead of build time; self-hosted deployments set the UI config through the new `UI_`-prefixed env vars (`UI_API_BASE_URL`, `UI_API_DOCS_URL`, `UI_GOOGLE_TAG_MANAGER_ID`, `UI_SENTRY_DSN`, `UI_SENTRY_ENVIRONMENT`), with the previous `NEXT_PUBLIC_*` names still honored as a deprecated fallback [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + +### 🐞 Fixed + +- `ui/.env` template now lists only the canonical `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT` names; the deprecated `NEXT_PUBLIC_SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_ENVIRONMENT` entries have been removed [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500) + +--- + +## [1.30.0] (Prowler v5.30.0) + +### 🚀 Added + +- DISA Okta IDaaS STIG V1R2 compliance framework support with its dedicated mapper, details panel, and icon [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428) - DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) ### 🔄 Changed - Renamed "Customer Support" to "Support Desk" in the side menu, showing it only in Prowler Cloud/Enterprise, while "Community Support" now shows only in Prowler OSS [(#11508)](https://github.com/prowler-cloud/prowler/pull/11508) +- Compliance detail page now shows a "still loading" retry state while the API warms its compliance catalog, instead of rendering an empty page [(#11530)](https://github.com/prowler-cloud/prowler/pull/11530) + +### 🐞 Fixed + +- Risk Pipeline Sankey chart now adapts height and node spacing for dense provider datasets, keeping provider and severity labels readable [(#11527)](https://github.com/prowler-cloud/prowler/pull/11527) --- @@ -54,6 +96,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Compliance page now loads the most recent scan when opened from the sidebar instead of showing the "no compliance data available" alert [(#11374)](https://github.com/prowler-cloud/prowler/pull/11374) - Invitation links now show specific expired, no-longer-valid, and invalid-token messages based on API error responses [(#11376)](https://github.com/prowler-cloud/prowler/pull/11376) +- Jira dispatch and provider connection-test polling no longer show a false timeout for longer-running tasks; both poll windows now extend to 60 seconds [(#11519)](https://github.com/prowler-cloud/prowler/pull/11519) ### 🔐 Security diff --git a/ui/Dockerfile b/ui/Dockerfile index 047df1171b..86673ba046 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -3,8 +3,8 @@ FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b1 LABEL maintainer="https://github.com/prowler-cloud" -# Enable corepack for pnpm -RUN corepack enable +# Patch Alpine OpenSSL runtime packages before all stages inherit the base image. +RUN apk upgrade --no-cache libcrypto3 libssl3 && corepack enable # Install dependencies only when needed FROM base AS deps @@ -16,6 +16,7 @@ WORKDIR /app # Install dependencies based on the preferred package manager COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY scripts ./scripts +ENV NODE_OPTIONS=--max-old-space-size=4096 RUN corepack install && pnpm install --frozen-lockfile @@ -35,12 +36,8 @@ RUN corepack install ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_PROWLER_RELEASE_VERSION ENV NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${NEXT_PUBLIC_PROWLER_RELEASE_VERSION} -ARG NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID -ENV NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID} -ARG NEXT_PUBLIC_API_BASE_URL -ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} -ARG NEXT_PUBLIC_API_DOCS_URL -ENV NEXT_PUBLIC_API_DOCS_URL=${NEXT_PUBLIC_API_DOCS_URL} + +# GTM / API base+docs URLs are runtime container env (prod stage), not build ARGs. RUN pnpm run build @@ -77,6 +74,12 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +# Runtime configuration is read by `node server.js` at container start and is +# NOT baked into the image. Supply it via your orchestrator (docker-compose, +# Helm/K8s): +# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot) +# - optional: UI_API_DOCS_URL, UI_GOOGLE_TAG_MANAGER_ID, UI_SENTRY_DSN, UI_SENTRY_ENVIRONMENT +# - reserved: POSTHOG_KEY, POSTHOG_HOST, REO_DEV_CLIENT_ID (no consumer yet) # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output CMD ["node", "server.js"] diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts index 39076666dd..7c76fa574b 100644 --- a/ui/__tests__/msw/handlers/attack-paths.ts +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -10,7 +10,7 @@ import type { QueryResultAttributes, } from "@/types/attack-paths"; -const API = process.env.NEXT_PUBLIC_API_BASE_URL; +const API = process.env.UI_API_BASE_URL; type JsonApiErrorBody = { errors: Array<{ detail: string; status: string }>; diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index d5f4fd4954..e06b06d29d 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -73,17 +73,33 @@ export const getComplianceOverviewMetadataInfo = async ({ } }; -export const getComplianceAttributes = async (complianceId: string) => { +export const getComplianceAttributes = async ( + complianceId: string, + scanId?: string, +) => { const headers = await getAuthHeaders({ contentType: false }); try { const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`); url.searchParams.append("filter[compliance_id]", complianceId); + // Pass the scan so multi-provider universal frameworks (e.g. CSA CCM) + // resolve the check IDs for the scan's provider instead of defaulting to + // the first provider that declares the framework. + if (scanId) { + url.searchParams.append("filter[scan_id]", scanId); + } const response = await fetch(url.toString(), { headers, }); + // The compliance catalog is still warming after a deploy/restart. Signal + // the page to render the "still loading" state instead of letting this + // become a thrown 5xx (which would be captured as a server error). + if (response.status === 503) { + return { warming: true as const, status: 503 }; + } + return handleApiResponse(response); } catch (error) { console.error("Error fetching compliance attributes:", error); diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 40084a0da1..8945842fe4 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -286,7 +286,7 @@ const pollTaskUntilComplete = async ( taskId: string, ): Promise => { const settled = await pollTaskUntilSettled(taskId, { - maxAttempts: 10, + maxAttempts: 20, delayMs: 3000, }); diff --git a/ui/actions/integrations/jira-dispatch.ts b/ui/actions/integrations/jira-dispatch.ts index 72bfc1176d..9e3b25679f 100644 --- a/ui/actions/integrations/jira-dispatch.ts +++ b/ui/actions/integrations/jira-dispatch.ts @@ -148,7 +148,7 @@ export const pollJiraDispatchTask = async ( { success: true; message: string } | { success: false; error: string } > => { const res = await pollTaskUntilSettled(taskId, { - maxAttempts: 10, + maxAttempts: 30, delayMs: 2000, }); if (!res.ok) { diff --git a/ui/actions/overview/regions/threat-map.adapter.test.ts b/ui/actions/overview/regions/threat-map.adapter.test.ts new file mode 100644 index 0000000000..515825bb0a --- /dev/null +++ b/ui/actions/overview/regions/threat-map.adapter.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { adaptRegionsOverviewToThreatMap } from "./threat-map.adapter"; +import type { RegionsOverviewResponse } from "./types"; + +function buildRegionsResponse( + rows: Array<{ providerType: string; region: string }>, +): RegionsOverviewResponse { + return { + data: rows.map(({ providerType, region }, index) => ({ + type: "regions-overview", + id: `region-${index}`, + attributes: { + provider_type: providerType, + region, + total: 10, + fail: 4, + muted: 0, + pass: 6, + }, + })), + meta: { version: "v1" }, + }; +} + +describe("adaptRegionsOverviewToThreatMap", () => { + it("maps okta regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "okta", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "okta", + region: "global", + name: "Okta - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); + + it("maps googleworkspace regions to a global location", () => { + const response = buildRegionsResponse([ + { providerType: "googleworkspace", region: "global" }, + ]); + + const result = adaptRegionsOverviewToThreatMap(response); + + expect(result.locations).toHaveLength(1); + expect(result.locations[0]).toMatchObject({ + providerType: "googleworkspace", + region: "global", + name: "Google Workspace - Global", + totalFindings: 10, + failFindings: 4, + }); + expect(result.regions).toEqual(["global"]); + }); +}); diff --git a/ui/actions/overview/regions/threat-map.adapter.ts b/ui/actions/overview/regions/threat-map.adapter.ts index 0b7852b56a..6ee0958aad 100644 --- a/ui/actions/overview/regions/threat-map.adapter.ts +++ b/ui/actions/overview/regions/threat-map.adapter.ts @@ -261,6 +261,19 @@ const ALIBABACLOUD_COORDINATES: Record = { global: { lat: 30.3, lng: 120.2 }, // Global fallback (Hangzhou HQ) }; +// Okta is a SaaS identity platform without user-facing regions +const OKTA_COORDINATES: Record = { + global: { lat: 37.8, lng: -122.4 }, // Global fallback (San Francisco HQ) +}; + +// Google Workspace is a SaaS suite without user-facing regions +const GOOGLEWORKSPACE_COORDINATES: Record< + string, + { lat: number; lng: number } +> = { + global: { lat: 37.4, lng: -122.1 }, // Global fallback (Mountain View HQ) +}; + const PROVIDER_COORDINATES: Record< string, Record @@ -277,6 +290,8 @@ const PROVIDER_COORDINATES: Record< oraclecloud: ORACLECLOUD_COORDINATES, mongodbatlas: MONGODBATLAS_COORDINATES, alibabacloud: ALIBABACLOUD_COORDINATES, + okta: OKTA_COORDINATES, + googleworkspace: GOOGLEWORKSPACE_COORDINATES, }; // Returns [lng, lat] format for D3/GeoJSON compatibility diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts index cbdb0b1296..d8c5d75456 100644 --- a/ui/actions/resources/resources.test.ts +++ b/ui/actions/resources/resources.test.ts @@ -28,6 +28,10 @@ import { vi.mock("@/lib", () => ({ apiBaseUrl: "https://api.example.com/api/v1", getAuthHeaders: getAuthHeadersMock, + GENERIC_SERVER_ERROR_MESSAGE: + "Server is temporarily unavailable. Please try again in a few minutes.", + sanitizeErrorMessage: (message: string, fallback: string) => + / { }); }); + it("returns a generic error when a gateway returns HTML", async () => { + // Given + const mockResponse = new Response( + "502 Bad Gateway

    502 Bad Gateway

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

    502 Bad Gateway

    ", + { + status: 502, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }, + ), + ); + + // When + const result = await getExportsZip("scan-123"); + + // Then + expect(result).toEqual({ + error: + "Server is temporarily unavailable. Please try again in a few minutes.", + }); + }); +}); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 318004ea96..5a6ddb7a29 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -3,7 +3,12 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib"; +import { + apiBaseUrl, + GENERIC_SERVER_ERROR_MESSAGE, + getAuthHeaders, + getErrorMessage, +} from "@/lib"; import { COMPLIANCE_REPORT_DISPLAY_NAMES, type ComplianceReportType, @@ -15,6 +20,7 @@ import { } from "@/lib/provider-filters"; import { addScanOperation } from "@/lib/sentry-breadcrumbs"; import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import { SCAN_STATES } from "@/types/attack-paths"; const ORGANIZATION_SCAN_CONCURRENCY_LIMIT = 5; export const getScans = async ({ @@ -64,6 +70,10 @@ export const getScansByState = async () => { "filter[provider_type__in]", sanitizeProviderTypesCsv(), ); + // Only need to know whether at least one completed scan exists; filter server-side + // and cap to a single row so the answer is correct regardless of total scan count. + url.searchParams.append("filter[state]", SCAN_STATES.COMPLETED); + url.searchParams.append("page[size]", "1"); try { const response = await fetch(url.toString(), { @@ -157,18 +167,20 @@ export const scheduleDaily = async (formData: FormData) => { const url = new URL(`${apiBaseUrl}/schedules/daily`); + const body = { + data: { + type: "daily-schedules", + attributes: { + provider_id: providerId, + }, + }, + }; + try { const response = await fetch(url.toString(), { method: "POST", headers, - body: JSON.stringify({ - data: { - type: "daily-schedules", - attributes: { - provider_id: providerId, - }, - }, - }), + body: JSON.stringify(body), }); return handleApiResponse(response, "/scans"); @@ -244,6 +256,27 @@ export const launchOrganizationScans = async ( return summary; }; +async function getScanReportErrorMessage( + response: Response, + fallbackMessage: string, +): Promise { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + + if (contentType.includes("text/html")) { + return GENERIC_SERVER_ERROR_MESSAGE; + } + + const errorData = await response.json().catch(() => null); + + return ( + errorData?.errors?.[0]?.detail || + errorData?.errors?.detail || + errorData?.error || + errorData?.message || + (response.status >= 500 ? GENERIC_SERVER_ERROR_MESSAGE : fallbackMessage) + ); +} + export const updateScan = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); @@ -295,11 +328,11 @@ export const getExportsZip = async (scanId: string) => { } if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, "Unable to fetch scan report. Contact support if the issue continues.", + ), ); } @@ -370,10 +403,11 @@ const _fetchScanBinary = async ( } if (!response.ok) { - const errorData = await response.json().catch(() => ({})); throw new Error( - errorData?.errors?.detail || + await getScanReportErrorMessage( + response, `Unable to retrieve ${errorLabel}. Contact support if the issue continues.`, + ), ); } diff --git a/ui/actions/schedules/index.ts b/ui/actions/schedules/index.ts new file mode 100644 index 0000000000..7e6e7b459a --- /dev/null +++ b/ui/actions/schedules/index.ts @@ -0,0 +1 @@ +export * from "./schedules"; diff --git a/ui/actions/schedules/schedules.test.ts b/ui/actions/schedules/schedules.test.ts new file mode 100644 index 0000000000..d676e12d67 --- /dev/null +++ b/ui/actions/schedules/schedules.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SCHEDULE_FREQUENCY, + type ScheduleUpdatePayload, +} from "@/types/schedules"; + +const { + fetchMock, + getAuthHeadersMock, + handleApiErrorMock, + handleApiResponseMock, + revalidatePathMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiErrorMock: vi.fn(), + handleApiResponseMock: vi.fn(), + revalidatePathMock: vi.fn(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: revalidatePathMock, +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiError: handleApiErrorMock, + handleApiResponse: handleApiResponseMock, +})); + +import { + getSchedule, + removeSchedule, + updateSchedule, + updateSchedulesBulk, +} from "./schedules"; + +const PROVIDER_ID = "1795f636-37e6-42f6-b158-d4faaa64e0fc"; +const SECOND_PROVIDER_ID = "9b7fae7d-5e72-49fe-8b0b-5bff5930db1a"; + +const payload = { + scan_enabled: true, + scan_frequency: SCHEDULE_FREQUENCY.DAILY, + scan_hour: 12, + scan_timezone: "Europe/Madrid", + scan_interval_hours: null, + scan_day_of_week: null, + scan_day_of_month: null, +}; + +describe("schedule write actions revalidate only on success", () => { + beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + handleApiErrorMock.mockReturnValue({ error: "Failed" }); + }); + + it("revalidates /scans and /providers after a successful update", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("posts the JSON:API bulk schedule payload", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [PROVIDER_ID, SECOND_PROVIDER_ID], + failed: [], + }, + }, + }); + + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/schedules/bulk", + expect.objectContaining({ + method: "POST", + headers: { Authorization: "Bearer token" }, + body: JSON.stringify({ + data: { + type: "schedules-bulk", + attributes: { + schedule: payload, + provider_ids: [PROVIDER_ID, SECOND_PROVIDER_ID], + }, + }, + }), + }), + ); + }); + + it("rejects invalid bulk provider ids without issuing a request", async () => { + expect( + await updateSchedulesBulk([PROVIDER_ID, "../users/me"], payload), + ).toEqual({ + error: "Invalid provider ids.", + }); + expect(await updateSchedulesBulk([], payload)).toEqual({ + error: "Invalid provider ids.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid bulk schedule payloads without issuing a request", async () => { + const invalidPayload = { + ...payload, + scan_hour: "12", + } as unknown as ScheduleUpdatePayload; + + expect( + await updateSchedulesBulk( + [PROVIDER_ID, SECOND_PROVIDER_ID], + invalidPayload, + ), + ).toEqual({ + error: "Invalid schedule payload.", + }); + expect(getAuthHeadersMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("normalizes bulk auth header errors", async () => { + const authError = new Error("Session expired"); + getAuthHeadersMock.mockRejectedValue(authError); + + expect( + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload), + ).toEqual({ error: "Failed" }); + expect(handleApiErrorMock).toHaveBeenCalledWith(authError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a partial bulk success", async () => { + handleApiResponseMock.mockResolvedValue({ + data: { + type: "schedules-bulk", + attributes: { + updated: [PROVIDER_ID], + failed: [{ provider_id: SECOND_PROVIDER_ID, error: "Denied" }], + }, + }, + }); + + const result = await updateSchedulesBulk( + [PROVIDER_ID, SECOND_PROVIDER_ID], + payload, + ); + + expect(result.data?.attributes?.updated).toEqual([PROVIDER_ID]); + expect(result.data?.attributes?.failed).toHaveLength(1); + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the bulk update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Bulk rejected" }); + + await updateSchedulesBulk([PROVIDER_ID, SECOND_PROVIDER_ID], payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("does not revalidate when the update returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Schedule rejected" }); + + await updateSchedule(PROVIDER_ID, payload); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("revalidates /scans and /providers after a successful delete", async () => { + handleApiResponseMock.mockResolvedValue({ success: true }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).toHaveBeenCalledWith("/scans"); + expect(revalidatePathMock).toHaveBeenCalledWith("/providers"); + }); + + it("does not revalidate when the delete returns an error result", async () => { + handleApiResponseMock.mockResolvedValue({ error: "Not allowed" }); + + await removeSchedule(PROVIDER_ID); + + expect(revalidatePathMock).not.toHaveBeenCalled(); + }); + + it("rejects non-UUID provider ids without issuing a request", async () => { + const malicious = "../users/me"; + + expect(await getSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(await updateSchedule(malicious, payload)).toEqual({ + error: "Invalid provider id.", + }); + expect(await removeSchedule(malicious)).toEqual({ + error: "Invalid provider id.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/actions/schedules/schedules.ts b/ui/actions/schedules/schedules.ts new file mode 100644 index 0000000000..6b197823eb --- /dev/null +++ b/ui/actions/schedules/schedules.ts @@ -0,0 +1,179 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; +import { scheduleUpdatePayloadSchema } from "@/lib/schedules"; +import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper"; +import type { + ScheduleProps, + SchedulesBulkResponse, + ScheduleUpdatePayload, +} from "@/types/schedules"; + +// SSRF guard: the id is interpolated into the request URL, so only UUIDs pass. +const providerIdSchema = z.uuid(); + +function parseProviderId(providerId: string): string | null { + const parsed = providerIdSchema.safeParse(providerId); + return parsed.success ? parsed.data : null; +} + +function parseProviderIds(providerIds: string[]): string[] | null { + if (providerIds.length === 0) return null; + + const ids = providerIds.map(parseProviderId); + return ids.every((id): id is string => id !== null) ? ids : null; +} + +function revalidateScheduleViews() { + revalidatePath("/scans"); + revalidatePath("/providers"); +} + +export const getSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + url.searchParams.set("include", "provider"); + + try { + const response = await fetch(url.toString(), { headers }); + + return handleApiResponse(response); + } catch (error) { + return handleApiError(error); + } +}; + +/** + * Lists every schedule (one per provider), following pagination — the backend + * has no multi-provider filter. + */ +export const getSchedules = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const schedules: ScheduleProps[] = []; + const MAX_PAGES = 20; + + try { + for (let page = 1; page <= MAX_PAGES; page++) { + const url = new URL(`${apiBaseUrl}/schedules`); + url.searchParams.set("page[number]", String(page)); + url.searchParams.set("page[size]", "100"); + + const response = await fetch(url.toString(), { headers }); + + const result = await handleApiResponse(response); + if (result?.error) return result; + + schedules.push(...(result?.data ?? [])); + + const totalPages = result?.meta?.pagination?.pages ?? 1; + if (page >= totalPages) break; + } + + return { data: schedules }; + } catch (error) { + return handleApiError(error); + } +}; + +export const updateSchedule = async ( + providerId: string, + payload: ScheduleUpdatePayload, +) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + const body = { + data: { + type: "schedules", + id, + attributes: payload, + }, + }; + + try { + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(body), + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + +export const updateSchedulesBulk = async ( + providerIds: string[], + payload: ScheduleUpdatePayload, +): Promise => { + const ids = parseProviderIds(providerIds); + if (!ids) return { error: "Invalid provider ids." }; + + const parsedPayload = scheduleUpdatePayloadSchema.safeParse(payload); + if (!parsedPayload.success) return { error: "Invalid schedule payload." }; + + try { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/bulk`); + const body = { + data: { + type: "schedules-bulk", + attributes: { + schedule: parsedPayload.data, + provider_ids: ids, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + const result = (await handleApiResponse(response)) as SchedulesBulkResponse; + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; + +export const removeSchedule = async (providerId: string) => { + const id = parseProviderId(providerId); + if (!id) return { error: "Invalid provider id." }; + + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/schedules/${id}`); + + try { + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + const result = await handleApiResponse(response); + if (!result?.error) { + revalidateScheduleViews(); + } + return result; + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts index d95966e680..41118bd6e2 100644 --- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.test.ts @@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => { describe("confirmAlertRecipient", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -104,7 +104,8 @@ describe("confirmAlertRecipient", () => { }); it("returns the fallback message when the API base URL is missing", async () => { - // Given + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); // When @@ -120,6 +121,21 @@ describe("confirmAlertRecipient", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await confirmAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/confirm?token=token-1", + ); + }); + it("returns the fallback message when the request fails", async () => { // Given fetchMock.mockRejectedValueOnce(new Error("network down")); diff --git a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts index b567bb4124..19e9216339 100644 --- a/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts +++ b/ui/app/(auth)/alerts/confirm/confirm-alert-recipient.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + interface AlertConfirmApiResponse { state?: string; message?: string; @@ -41,7 +43,7 @@ const toState = (payload: unknown): string => { export const confirmAlertRecipient = async ( token?: string, ): Promise => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); if (!apiBaseUrl) { return { ok: false, diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts index 8a595fa63e..508625b4be 100644 --- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.test.ts @@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => { describe("unsubscribeAlertRecipient", () => { beforeEach(() => { vi.stubGlobal("fetch", fetchMock); - vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1"); + vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1"); fetchMock.mockResolvedValue( new Response( JSON.stringify({ @@ -102,4 +102,37 @@ describe("unsubscribeAlertRecipient", () => { "https://api.example.com/api/v1/alerts/recipients/unsubscribe", ); }); + + it("returns the fallback message when the API base URL is missing", async () => { + // Given - neither the new name nor its legacy fallback is set + vi.stubEnv("UI_API_BASE_URL", ""); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", ""); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result).toEqual({ + ok: false, + state: "missing_api_base_url", + message: + "We could not process this unsubscribe link. Please try again later.", + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => { + // Given - only the legacy name is configured + vi.stubEnv("UI_API_BASE_URL", undefined); + vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1"); + + // When + const result = await unsubscribeAlertRecipient("token-1"); + + // Then + expect(result.ok).toBe(true); + expect(lastFetchCall().url).toBe( + "https://legacy.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1", + ); + }); }); diff --git a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts index af64165a1e..8c3a4e2a79 100644 --- a/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts +++ b/ui/app/(auth)/alerts/unsubscribe/unsubscribe-alert-recipient.ts @@ -1,3 +1,5 @@ +import { readEnv } from "@/lib/runtime-env"; + interface AlertUnsubscribeApiResponse { state?: string; message?: string; @@ -41,7 +43,7 @@ const toState = (payload: unknown): string => { export const unsubscribeAlertRecipient = async ( token?: string, ): Promise => { - const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"); if (!apiBaseUrl) { return { ok: false, diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index 07fe3a60c3..79a4ce4e89 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -2,12 +2,15 @@ import "@/styles/globals.css"; import { GoogleTagManager } from "@next/third-parties/google"; import { Metadata, Viewport } from "next"; +import { connection } from "next/server"; import { ReactNode, Suspense } from "react"; +import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress, Toaster } from "@/components/ui"; import { fontSans } from "@/config/fonts"; import { siteConfig } from "@/config/site"; import { cn } from "@/lib"; +import { readEnv } from "@/lib/runtime-env"; import { Providers } from "../providers"; @@ -29,10 +32,27 @@ export const viewport: Viewport = { ], }; -export default function AuthLayout({ children }: { children: ReactNode }) { +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + // Force dynamic rendering so the read below resolves from the container env + // at request time rather than being snapshotted at build (independent of the + // island's own connection() call). + await connection(); + + // Server-side runtime read. Empty/unset id ⇒ GoogleTagManager is not mounted + const gtmId = readEnv( + "UI_GOOGLE_TAG_MANAGER_ID", + "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID", + ); + return ( - + + + {children} - + {gtmId && } diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index be336be2a7..026ba439f1 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -14,6 +14,7 @@ import { } from "@/app/(prowler)/alerts/_types"; import type { ProviderProps } from "@/types/providers"; +import { ALERTS_PERMISSION_ERROR } from "../../_lib/alert-errors"; import { AlertFormModal } from "../alert-form-modal"; const recipientsActionMocks = vi.hoisted(() => ({ @@ -701,6 +702,53 @@ describe("AlertFormModal", () => { expect(errorMessage).toHaveClass("text-text-error-primary"); }); + it("should clear the preview error when save shows the form error", async () => { + // Given + const user = userEvent.setup(); + alertsActionMocks.seedAlertRule.mockResolvedValue({ + error: "No alert-compatible filters", + }); + mockRecipientsList(); + renderCreateModal({ editingAlert: createEditingAlert() }); + + // When + await user.click(screen.getByRole("button", { name: /^test$/i })); + expect(await screen.findByText("Test result")).toBeVisible(); + await user.click(screen.getByRole("button", { name: /^save$/i })); + + // Then + await waitFor(() => + expect(screen.queryByText("Test result")).not.toBeInTheDocument(), + ); + expect( + screen.getAllByText( + "Apply at least one alert-compatible Findings filter.", + ), + ).toHaveLength(1); + }); + + it("should show the manage alerts permission error when save seed is forbidden", async () => { + // Given + const user = userEvent.setup(); + alertsActionMocks.seedAlertRule.mockResolvedValue({ + error: "You do not have permission to perform this action.", + status: 403, + }); + mockRecipientsList(); + renderCreateModal({ editingAlert: createEditingAlert() }); + + // When + await user.click(screen.getByRole("button", { name: /^save$/i })); + + // Then + expect(await screen.findByText(ALERTS_PERMISSION_ERROR)).toBeVisible(); + expect( + screen.queryByText( + "Apply at least one alert-compatible Findings filter.", + ), + ).not.toBeInTheDocument(); + }); + it("should hydrate advanced edit mode filters and normalize them on save", async () => { // Given const user = userEvent.setup(); diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx index 1ee2c67302..c8a6c9fb61 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alerts-manager.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { isValidElement, type ReactNode } from "react"; +import { isValidElement, type ReactNode, useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -8,7 +8,12 @@ import { ALERT_TRIGGER_KINDS, type AlertRule, } from "@/app/(prowler)/alerts/_types"; +import type { + AlertFormSubmitResult, + AlertFormValues, +} from "@/app/(prowler)/alerts/_types/alert-form"; +import { ALERTS_PERMISSION_ERROR } from "../../_lib/alert-errors"; import { AlertsManager } from "../alerts-manager"; const actionMocks = vi.hoisted(() => ({ @@ -96,12 +101,32 @@ vi.mock("../alert-form-modal", () => ({ open, editingAlert, onOpenChange, + onSubmit, }: { open: boolean; editingAlert?: AlertRule | null; onOpenChange: (open: boolean) => void; - }) => - open ? ( + onSubmit: (values: AlertFormValues) => Promise; + }) => { + const [error, setError] = useState(null); + + const submit = async () => { + const result = await onSubmit({ + name: "Updated alert", + description: "", + method: "email", + frequency: ALERT_TRIGGER_KINDS.AFTER_SCAN, + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + recipientEmails: [], + enabled: true, + }); + setError(result.ok ? null : (result.error ?? null)); + }; + + return open ? (
    ({ + {editingAlert?.attributes.name} + {error &&

    {error}

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

    {description}

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

    {description}

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

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

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

    Loading scans...

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

    Loading queries...

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

    + Compliance data is still loading +

    +

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

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