diff --git a/.env b/.env index c6085538a0..c0364e29ae 100644 --- a/.env +++ b/.env @@ -145,7 +145,7 @@ SENTRY_RELEASE=local NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} #### 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/actions/osv-scanner/action.yml b/.github/actions/osv-scanner/action.yml index 4bfb5993cb..de5116fdcf 100644 --- a/.github/actions/osv-scanner/action.yml +++ b/.github/actions/osv-scanner/action.yml @@ -1,5 +1,5 @@ name: 'OSV-Scanner' -description: 'Install osv-scanner and scan a lockfile, failing on HIGH/CRITICAL/UNKNOWN severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).' +description: 'Install osv-scanner and scan a lockfile, failing on CRITICAL severity findings. Posts/updates a PR comment with findings on pull_request events (requires pull-requests: write).' author: 'Prowler' inputs: @@ -7,9 +7,9 @@ inputs: description: 'Path to the lockfile to scan, relative to the repository root (e.g. uv.lock, api/uv.lock, ui/pnpm-lock.yaml).' required: true severity-levels: - description: 'Comma-separated severity levels that fail the scan. Default: HIGH,CRITICAL,UNKNOWN.' + description: 'Comma-separated severity levels that fail the scan. Default: CRITICAL.' required: false - default: 'HIGH,CRITICAL,UNKNOWN' + default: 'CRITICAL' version: description: 'osv-scanner release tag to install. When overriding, you MUST also override binary-sha256.' required: false diff --git a/.github/actions/setup-python-uv/action.yml b/.github/actions/setup-python-uv/action.yml index e23388d86e..14b9b81d15 100644 --- a/.github/actions/setup-python-uv/action.yml +++ b/.github/actions/setup-python-uv/action.yml @@ -43,8 +43,17 @@ runs: if: github.repository_owner == 'prowler-cloud' && github.repository != 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + LATEST_COMMIT=$(curl -sf \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ + | jq -er '.sha') || { + echo "::error::Failed to fetch latest prowler/master commit from the GitHub API (HTTP error or missing .sha). Check the GITHUB_TOKEN and API rate limits." + exit 1 + } echo "Latest commit hash: $LATEST_COMMIT" sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock echo "Updated uv.lock entry:" @@ -54,8 +63,17 @@ runs: if: github.event_name == 'push' && github.ref == 'refs/heads/master' && github.repository == 'prowler-cloud/prowler' shell: bash working-directory: ${{ inputs.working-directory }} + env: + GITHUB_TOKEN: ${{ github.token }} run: | - LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + LATEST_COMMIT=$(curl -sf \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/prowler-cloud/prowler/commits/master" \ + | jq -er '.sha') || { + echo "::error::Failed to fetch latest prowler/master commit from the GitHub API (HTTP error or missing .sha). Check the GITHUB_TOKEN and API rate limits." + exit 1 + } echo "Latest commit hash: $LATEST_COMMIT" sed -i "s|\(git = \"https://github\.com/prowler-cloud/prowler\.git?rev=master\)#[a-f0-9]\{40\}\"|\1#${LATEST_COMMIT}\"|g" uv.lock echo "Updated uv.lock entry:" diff --git a/.github/actions/trivy-scan/action.yml b/.github/actions/trivy-scan/action.yml index 9073e2fbcb..45034ddd6a 100644 --- a/.github/actions/trivy-scan/action.yml +++ b/.github/actions/trivy-scan/action.yml @@ -63,7 +63,7 @@ runs: exit-code: '0' scanners: 'vuln' timeout: '5m' - version: 'v0.69.2' + version: 'v0.71.0' - name: Run Trivy vulnerability scan (SARIF) if: inputs.upload-sarif == 'true' && github.event_name == 'push' @@ -76,7 +76,7 @@ runs: exit-code: '0' scanners: 'vuln' timeout: '5m' - version: 'v0.69.2' + version: 'v0.71.0' - name: Upload Trivy results to GitHub Security tab if: inputs.upload-sarif == 'true' && github.event_name == 'push' diff --git a/.github/scripts/osv-scan.sh b/.github/scripts/osv-scan.sh index d9c2e1a901..16afc6668c 100755 --- a/.github/scripts/osv-scan.sh +++ b/.github/scripts/osv-scan.sh @@ -6,8 +6,7 @@ # - .github/workflows/api-security.yml, sdk-security.yml, ui-security.yml # # Severity levels (comma-separated) are read from OSV_SEVERITY_LEVELS. -# Default: HIGH,CRITICAL,UNKNOWN — preserves prior .safety-policy.yml policy -# (ignore-cvss-severity-below: 7 + ignore-cvss-unknown-severity: False). +# Default: CRITICAL — only CVSS >= 9.0 findings fail the scan. # osv-scanner has no native CVSS threshold (google/osv-scanner#1400, closed # not-planned). Severity is derived from $group.max_severity (numeric CVSS # score string) which osv-scanner emits per group. @@ -33,7 +32,7 @@ set -euo pipefail ROOT="$(git rev-parse --show-toplevel)" CONFIG="${ROOT}/osv-scanner.toml" -SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-HIGH,CRITICAL,UNKNOWN}" +SEVERITY_LEVELS="${OSV_SEVERITY_LEVELS:-CRITICAL}" for bin in osv-scanner jq; do if ! command -v "${bin}" >/dev/null 2>&1; then diff --git a/.github/workflows/api-container-checks.yml b/.github/workflows/api-container-checks.yml index 44482e2428..d12bf5d2d4 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -12,9 +12,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'api/**' - - '.github/workflows/api-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -134,5 +131,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index ba7f5fe58a..ed4e5476d2 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -16,13 +16,6 @@ on: branches: - "master" - "v5.*" - paths: - - 'api/**' - - '.github/workflows/api-tests.yml' - - '.github/workflows/api-security.yml' - - '.github/actions/setup-python-uv/**' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index 7493e1b763..23e6ba2984 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -12,9 +12,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'mcp_server/**' - - '.github/workflows/mcp-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -127,5 +124,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/mcp-security.yml b/.github/workflows/mcp-security.yml index 66a5cd8c0d..4deb6a478d 100644 --- a/.github/workflows/mcp-security.yml +++ b/.github/workflows/mcp-security.yml @@ -15,12 +15,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'mcp_server/pyproject.toml' - - 'mcp_server/uv.lock' - - '.github/workflows/mcp-security.yml' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -30,7 +24,6 @@ permissions: {} jobs: mcp-security-scans: - if: github.repository == 'prowler-cloud/prowler' runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/.github/workflows/sdk-code-quality.yml b/.github/workflows/sdk-code-quality.yml index 5c76547989..2b1efc69a9 100644 --- a/.github/workflows/sdk-code-quality.yml +++ b/.github/workflows/sdk-code-quality.yml @@ -29,6 +29,7 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' steps: - name: Harden Runner diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 42709ac6f7..2b436db468 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -15,12 +15,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'Dockerfile*' - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/sdk-container-checks.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -111,25 +105,14 @@ jobs: id: check-changes uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: - files: ./** + files: | + prowler/** + Dockerfile* + pyproject.toml + uv.lock + .github/workflows/sdk-container-checks.yml files_ignore: | - .github/** prowler/CHANGELOG.md - docs/** - permissions/** - api/** - ui/** - dashboard/** - mcp_server/** - skills/** - README.md - mkdocs.yml - .backportrc.json - .env - docker-compose* - examples/** - .gitignore - contrib/** **/AGENTS.md - name: Set up Docker Buildx @@ -153,5 +136,5 @@ jobs: with: image-name: ${{ env.IMAGE_NAME }} image-tag: ${{ github.sha }} - fail-on-critical: 'false' + fail-on-critical: 'true' severity: 'CRITICAL' diff --git a/.github/workflows/sdk-security.yml b/.github/workflows/sdk-security.yml index c18f56ea8f..11a7894ce8 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -19,16 +19,6 @@ on: branches: - 'master' - 'v5.*' - paths: - - 'prowler/**' - - 'tests/**' - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/sdk-tests.yml' - - '.github/workflows/sdk-security.yml' - - '.github/actions/setup-python-uv/**' - - '.github/actions/osv-scanner/**' - - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -71,27 +61,18 @@ jobs: id: check-changes uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: - files: - ./** + files: | + prowler/** + tests/** + pyproject.toml + uv.lock + .github/workflows/sdk-tests.yml .github/workflows/sdk-security.yml + .github/actions/setup-python-uv/** + .github/actions/osv-scanner/** + .github/scripts/osv-scan.sh files_ignore: | - .github/** prowler/CHANGELOG.md - docs/** - permissions/** - api/** - ui/** - dashboard/** - mcp_server/** - skills/** - README.md - mkdocs.yml - .backportrc.json - .env - docker-compose* - examples/** - .gitignore - contrib/** **/AGENTS.md - name: Setup Python with uv diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 59316c0d95..2952ebc2d5 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -29,6 +29,7 @@ jobs: - '3.10' - '3.11' - '3.12' + - '3.13' steps: - name: Harden Runner @@ -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,7 @@ jobs: with: flags: prowler-py${{ matrix.python-version }}-stackit files: ./stackit_coverage.xml - + # External Provider (dynamic loading) - name: Check if External Provider files changed if: steps.check-changes.outputs.any_changed == 'true' @@ -608,14 +609,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-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 1a3653f8c9..ed407e890d 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -77,6 +77,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 }} 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/api/CHANGELOG.md b/api/CHANGELOG.md index 6759b28741..db5cc69b03 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,7 +2,46 @@ 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) + +### 🔄 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) + +### 🔐 Security + +- `aiohttp` to 3.14.0 and `idna` to 3.15, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- Container base image to `python:3.12.13-slim-bookworm` and `trivy` to 0.71.0, patching OS and Go module CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- `trivy` binary bumped to 0.71.0 patching embedded `golang.org/x/crypto`, `golang.org/x/net`, and Go `stdlib` CVEs [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592) + +--- + +## [1.31.2] (Prowler v5.30.2) + +### 🔄 Changed + +- `scan-compliance-overviews` task now streams the findings aggregation and the requirement-row writes so it runs faster and its peak memory no longer grows with the number of regions and frameworks [(#11591)](https://github.com/prowler-cloud/prowler/pull/11591) + +--- + +## [1.31.1] (Prowler v5.30.1) + +### 🐞 Fixed + +- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546) +- Attack Paths: `drop_subgraph` now deletes relationships first and then nodes in batches, using less memory on Neo4j when clearing a dense provider graph [(#11557)](https://github.com/prowler-cloud/prowler/pull/11557) +- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558) + +--- + +## [1.31.0] (Prowler v5.30.0) ### 🚀 Added @@ -14,16 +53,17 @@ All notable changes to the **Prowler API** are documented in this file. ### 🔄 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 a background thread after each worker forks, and `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the Gunicorn worker timeout [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) +- 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/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/pyproject.toml b/api/pyproject.toml index 5e18ffac20..41014d3382 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -41,7 +41,9 @@ dependencies = [ "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", "defusedxml==0.7.1", - "gunicorn==23.0.0", + "django-eventstream==5.3.3", + "gunicorn==26.0.0", + "uvloop==0.22.1", "lxml==6.1.0", "prowler @ git+https://github.com/prowler-cloud/prowler.git@master", "psycopg2-binary==2.9.9", @@ -68,7 +70,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.31.0" +version = "1.32.0" [tool.uv] # Transitive pins matching master to avoid silent drift; bump deliberately. @@ -79,7 +81,7 @@ constraint-dependencies = [ "aiobotocore==2.25.1", "aiofiles==24.1.0", "aiohappyeyeballs==2.6.1", - "aiohttp==3.13.5", + "aiohttp==3.14.0", "aioitertools==0.13.0", "aiosignal==1.4.0", "alibabacloud-actiontrail20200706==2.4.1", @@ -124,9 +126,8 @@ constraint-dependencies = [ "astroid==3.2.4", "async-timeout==5.0.1", "attrs==25.4.0", - "authlib==1.6.9", + "authlib==1.6.12", "autopep8==2.3.2", - "awsipranges==0.3.3", "azure-cli-core==2.83.0", "azure-cli-telemetry==1.1.0", "azure-common==1.1.28", @@ -209,6 +210,7 @@ constraint-dependencies = [ "django-celery-results==2.6.0", "django-cors-headers==4.4.0", "django-environ==0.11.2", + "django-eventstream==5.3.3", "django-filter==24.3", "django-guid==3.5.0", "django-postgres-extra==2.0.9", @@ -253,7 +255,7 @@ constraint-dependencies = [ "grpc-google-iam-v1==0.14.3", "grpcio==1.76.0", "grpcio-status==1.76.0", - "gunicorn==23.0.0", + "gunicorn==26.0.0", "h11==0.16.0", "h2==4.3.0", "hpack==4.1.0", @@ -262,8 +264,8 @@ constraint-dependencies = [ "httpx==0.28.1", "humanfriendly==10.0", "hyperframe==6.1.0", - "iamdata==0.1.202602021", - "idna==3.11", + "iamdata==0.1.202605131", + "idna==3.15", "importlib-metadata==8.7.1", "inflection==0.5.1", "iniconfig==2.3.0", @@ -315,7 +317,7 @@ constraint-dependencies = [ "neo4j==6.1.0", "nest-asyncio==1.6.0", "nltk==3.9.4", - "numpy==2.0.2", + "numpy==2.2.6", "oauthlib==3.3.1", "oci==2.169.0", "openai==1.109.1", @@ -344,7 +346,7 @@ constraint-dependencies = [ "psutil==7.2.2", "psycopg2-binary==2.9.9", "py-deviceid==0.1.1", - "py-iam-expand==0.1.0", + "py-iam-expand==0.3.0", "py-ocsf-models==0.8.1", "pyasn1==0.6.3", "pyasn1-modules==0.4.2", @@ -420,6 +422,7 @@ constraint-dependencies = [ "uritemplate==4.2.0", "urllib3==2.7.0", "uuid6==2024.7.10", + "uvloop==0.22.1", "vine==5.1.0", "vulture==2.14", "wcwidth==0.5.3", 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/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/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_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_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 ab90bdbde0..35098c1907 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 ): @@ -9849,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( @@ -10396,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 ): @@ -11164,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( @@ -11174,6 +12020,7 @@ class TestOverviewViewSet: authenticated_client, tenants_fixture, providers_fixture, + provider_groups_fixture, create_scan_category_summary, filter_key, filter_value_fn, @@ -11182,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", @@ -11207,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"] @@ -11381,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( @@ -11392,6 +12261,7 @@ class TestOverviewViewSet: authenticated_client, tenants_fixture, providers_fixture, + provider_groups_fixture, create_scan_resource_group_summary, filter_key, filter_value_fn, @@ -11401,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", @@ -11426,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"] @@ -11601,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 @@ -12658,7 +13581,7 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["no_permissions"], + "userType": ["platform_team"], }, ) @@ -12713,12 +13636,21 @@ class TestTenantFinishACSView: assert user.name == "John Doe" assert user.company_name == "testing_company" - role = Role.objects.using(MainRouter.admin_db).get(name="no_permissions") + role = Role.objects.using(MainRouter.admin_db).get( + name="platform_team", tenant=tenants_fixture[0] + ) assert role.tenant == tenants_fixture[0] + assert not role.manage_users + assert not role.manage_account + assert not role.manage_billing + assert not role.manage_providers + assert not role.manage_integrations + assert not role.manage_scans + assert role.unlimited_visibility assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(user=user, tenant_id=tenants_fixture[0].id) + .filter(user=user, role=role, tenant_id=tenants_fixture[0].id) .exists() ) @@ -12771,7 +13703,7 @@ class TestTenantFinishACSView: assert response.status_code == 302 assert "sso_saml_failed=true" in response.url - def test_dispatch_skips_role_mapping_when_single_manage_account_user( + def test_dispatch_keeps_existing_roles_when_usertype_missing( self, create_test_user, tenants_fixture, @@ -12780,7 +13712,7 @@ class TestTenantFinishACSView: settings, monkeypatch, ): - """Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role""" + """Test that roles are left untouched when the IdP does not send userType""" monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") user = create_test_user tenant = tenants_fixture[0] @@ -12789,6 +13721,7 @@ class TestTenantFinishACSView: UserRoleRelationship.objects.using(MainRouter.admin_db).create( user=user, role=admin_role, tenant_id=tenant.id ) + roles_before = Role.objects.using(MainRouter.admin_db).count() social_account = SocialAccount( user=user, @@ -12797,7 +13730,6 @@ class TestTenantFinishACSView: "firstName": ["John"], "lastName": ["Doe"], "organization": ["testing_company"], - "userType": ["no_permissions"], # This should be ignored }, ) @@ -12835,24 +13767,162 @@ class TestTenantFinishACSView: assert response.status_code == 302 - # Verify the admin role is still assigned (not changed to no_permissions) + # Verify the existing role assignment was not modified assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) .filter(user=user, role=admin_role, tenant_id=tenant.id) .exists() ) - # Verify no_permissions role was NOT created in the database - assert ( - not Role.objects.using(MainRouter.admin_db) - .filter(name="no_permissions", tenant=tenant) + # Verify no new role was created + assert Role.objects.using(MainRouter.admin_db).count() == roles_before + + def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing( + self, + create_test_user, + tenants_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that a user without roles gets none assigned when userType is missing""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + roles_before = Role.objects.using(MainRouter.admin_db).count() + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + # Verify no role was created or assigned + assert Role.objects.using(MainRouter.admin_db).count() == roles_before + assert not ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, tenant_id=tenant.id) .exists() ) - # Verify no_permissions role was NOT assigned to the user - assert not ( + # Membership is still created so the user belongs to the tenant + assert ( + Membership.objects.using(MainRouter.admin_db) + .filter(user=user, tenant=tenant) + .exists() + ) + + def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role( + self, + create_test_user, + tenants_fixture, + admin_role_fixture, + saml_setup, + settings, + monkeypatch, + ): + """Test that a new read-only role is neither created nor assigned if it would remove the last MANAGE_ACCOUNT user""" + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + tenant = tenants_fixture[0] + + admin_role = admin_role_fixture + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, role=admin_role, tenant_id=tenant.id + ) + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": ["brand_new_role"], + }, + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = user + request.session = {} + + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = user + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 302 + + # The admin role is still assigned and the new role was not created + assert ( UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(user=user, role__name="no_permissions", tenant_id=tenant.id) + .filter(user=user, role=admin_role, tenant_id=tenant.id) + .exists() + ) + assert ( + not Role.objects.using(MainRouter.admin_db) + .filter(name="brand_new_role", tenant=tenant) .exists() ) @@ -16753,6 +17823,44 @@ class TestFindingGroupViewSet: # All fixture findings are from AWS provider assert len(response.json()["data"]) == 5 + def test_finding_groups_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + finding_groups_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-group-list"), + {"filter[inserted_at]": TODAY, "filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 4 + + response = authenticated_client.get( + reverse("finding-group-list"), + { + "filter[inserted_at]": TODAY, + "filter[provider_groups__in]": f"{group1.id},{group2.id}", + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 5 + def test_finding_groups_check_id_filter( self, authenticated_client, finding_groups_fixture ): @@ -17663,6 +18771,41 @@ class TestFindingGroupViewSet: # All providers in fixture are AWS assert len(data) == 5 + def test_finding_groups_latest_provider_groups_filter( + self, + authenticated_client, + tenants_fixture, + finding_groups_fixture, + providers_fixture, + provider_groups_fixture, + ): + tenant = tenants_fixture[0] + provider1, provider2, *_ = providers_fixture + group1, group2, *_ = provider_groups_fixture + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group1 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider1, provider_group=group2 + ) + ProviderGroupMembership.objects.create( + tenant=tenant, provider=provider2, provider_group=group2 + ) + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[provider_groups]": str(group1.id)}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 4 + + response = authenticated_client.get( + reverse("finding-group-latest"), + {"filter[provider_groups__in]": f"{group1.id},{group2.id}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 5 + def test_finding_groups_latest_check_id_filter( self, authenticated_client, finding_groups_fixture ): diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index 8da8d91226..678ed24772 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -243,6 +243,12 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "filter_accounts": [provider.uid], } + elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value: + if isinstance(prowler_provider_kwargs.get("region"), str): + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "region": {prowler_provider_kwargs["region"]}, + } elif provider.provider == Provider.ProviderChoices.OPENSTACK.value: # clouds_yaml_content, clouds_yaml_cloud and provider_id are validated # in the provider itself, so it's not needed here. diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py index e1a5d3470f..849c788366 100644 --- a/api/src/backend/api/v1/mixins.py +++ b/api/src/backend/api/v1/mixins.py @@ -1,6 +1,10 @@ +import uuid + +from django.http import QueryDict from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from api.exceptions import ( @@ -8,7 +12,7 @@ from api.exceptions import ( TaskInProgressException, TaskNotFoundException, ) -from api.models import StateChoices, Task +from api.models import Provider, StateChoices, Task from api.v1.serializers import TaskSerializer @@ -74,6 +78,162 @@ class PaginateByPkMixin: return self.get_paginated_response(serialized) +class JsonApiFilterMixin: + """Shared helpers for manually applying django-filter to JSON:API params.""" + + jsonapi_filter_replace_dots = False + + def _normalize_jsonapi_params( + self, + query_params, + exclude_keys=None, + replace_dots=None, + ): + exclude_keys = exclude_keys or set() + if replace_dots is None: + replace_dots = self.jsonapi_filter_replace_dots + + normalized = QueryDict(mutable=True) + for key, values in query_params.lists(): + normalized_key = ( + key[7:-1] if key.startswith("filter[") and key.endswith("]") else key + ) + if replace_dots: + normalized_key = normalized_key.replace(".", "__") + if normalized_key not in exclude_keys: + normalized.setlist(normalized_key, values) + return normalized + + def _apply_filterset( + self, + queryset, + filterset_class, + exclude_keys=None, + replace_dots=None, + ): + normalized_params = self._normalize_jsonapi_params( + self.request.query_params, + exclude_keys=set(exclude_keys or []), + replace_dots=replace_dots, + ) + filterset = filterset_class(normalized_params, queryset=queryset) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + return filterset.qs + + +class ProviderFilterParamsMixin(JsonApiFilterMixin): + """Shared extraction of provider filters from JSON:API query params.""" + + PROVIDER_FILTER_KEYS = frozenset( + { + "provider_id", + "provider_id__in", + "provider_type", + "provider_type__in", + "provider_groups", + "provider_groups__in", + } + ) + PROVIDER_FILTER_DOT_ALIAS_KEYS = frozenset( + { + "provider_id.in", + "provider_type.in", + "provider_groups.in", + } + ) + PROVIDER_FILTER_QUERY_KEYS = PROVIDER_FILTER_KEYS | PROVIDER_FILTER_DOT_ALIAS_KEYS + + def _csv_filter_values(self, value): + return [item.strip() for item in value.split(",") if item.strip()] + + def _validate_uuid_filter_values(self, field_name, values): + try: + for value in values: + uuid.UUID(str(value)) + except (TypeError, ValueError, AttributeError): + raise ValidationError({field_name: ["Enter a valid UUID."]}) + + def _has_provider_filters(self, include_dot_aliases=False): + provider_filter_keys = ( + self.PROVIDER_FILTER_QUERY_KEYS + if include_dot_aliases + else self.PROVIDER_FILTER_KEYS + ) + return any( + self.request.query_params.get(f"filter[{key}]") + for key in provider_filter_keys + ) + + def _extract_provider_filters_from_params( + self, + *, + validate_uuids=False, + include_dot_aliases=False, + ): + params = self.request.query_params + filters = {} + valid_provider_types = { + choice[0] for choice in Provider.ProviderChoices.choices + } + + provider_id = params.get("filter[provider_id]") + if provider_id: + if validate_uuids: + self._validate_uuid_filter_values("provider_id", [provider_id]) + filters["provider_id"] = provider_id + + provider_id_in = params.get("filter[provider_id__in]") + if include_dot_aliases: + provider_id_in = provider_id_in or params.get("filter[provider_id.in]") + if provider_id_in: + values = self._csv_filter_values(provider_id_in) + if validate_uuids: + self._validate_uuid_filter_values("provider_id__in", values) + filters["provider_id__in"] = values + + provider_type = params.get("filter[provider_type]") + if provider_type: + if provider_type not in valid_provider_types: + raise ValidationError( + {"provider_type": f"Invalid choice: {provider_type}"} + ) + filters["provider__provider"] = provider_type + + provider_type_in = params.get("filter[provider_type__in]") + if include_dot_aliases: + provider_type_in = provider_type_in or params.get( + "filter[provider_type.in]" + ) + if provider_type_in: + values = self._csv_filter_values(provider_type_in) + invalid = [value for value in values if value not in valid_provider_types] + if invalid: + raise ValidationError( + {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} + ) + filters["provider__provider__in"] = values + + provider_groups = params.get("filter[provider_groups]") + if provider_groups: + if validate_uuids: + self._validate_uuid_filter_values("provider_groups", [provider_groups]) + filters["provider__provider_groups__id"] = provider_groups + + provider_groups_in = params.get("filter[provider_groups__in]") + if include_dot_aliases: + provider_groups_in = provider_groups_in or params.get( + "filter[provider_groups.in]" + ) + if provider_groups_in: + values = self._csv_filter_values(provider_groups_in) + if validate_uuids: + self._validate_uuid_filter_values("provider_groups__in", values) + filters["provider__provider_groups__id__in"] = values + + return filters + + class TaskManagementMixin: """ Mixin to manage task status checking. diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 45100f1018..7e9aae5b94 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, @@ -227,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, @@ -800,60 +807,70 @@ class TenantFinishACSView(FinishACSView): .tenant ) + # Only remap roles when the IdP provides a userType attribute. + # Without it, the user's current roles are left untouched. role_name = ( - extra.get("userType", ["no_permissions"])[0].strip() - if extra.get("userType") - else "no_permissions" + extra.get("userType", [""])[0].strip() if extra.get("userType") else "" ) - role = ( - Role.objects.using(MainRouter.admin_db) - .filter(name=role_name, tenant=tenant) - .first() - ) - - # Only skip mapping if it would remove the last MANAGE_ACCOUNT user - remaining_manage_account_users = ( - UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(role__manage_account=True, tenant_id=tenant.id) - .exclude(user_id=user_id) - .values("user") - .distinct() - .count() - ) - user_has_manage_account = ( - UserRoleRelationship.objects.using(MainRouter.admin_db) - .filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id) - .exists() - ) - role_manage_account = role.manage_account if role else False - would_remove_last_manage_account = ( - user_has_manage_account - and remaining_manage_account_users == 0 - and not role_manage_account - ) - - if not would_remove_last_manage_account: - if role is None: - role = Role.objects.using(MainRouter.admin_db).create( - name=role_name, - tenant=tenant, - manage_users=False, - manage_account=False, - manage_billing=False, - manage_providers=False, - manage_integrations=False, - manage_scans=False, - unlimited_visibility=False, + if role_name: + with transaction.atomic(using=MainRouter.admin_db): + role = ( + Role.objects.using(MainRouter.admin_db) + .filter(name=role_name, tenant=tenant) + .first() ) - UserRoleRelationship.objects.using(MainRouter.admin_db).filter( - user=user, - tenant_id=tenant.id, - ).delete() - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=user, - role=role, - tenant_id=tenant.id, - ) + + # Only skip mapping if it would remove the last MANAGE_ACCOUNT user + remaining_manage_account_users = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(role__manage_account=True, tenant_id=tenant.id) + .exclude(user_id=user_id) + .values("user") + .distinct() + .count() + ) + user_has_manage_account = ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter( + role__manage_account=True, + tenant_id=tenant.id, + user_id=user_id, + ) + .exists() + ) + role_manage_account = role.manage_account if role else False + would_remove_last_manage_account = ( + user_has_manage_account + and remaining_manage_account_users == 0 + and not role_manage_account + ) + + if not would_remove_last_manage_account: + if role is None: + # Roles auto-created from userType get read-only access: + # visibility over all providers, no management permissions + role, _ = Role.objects.using(MainRouter.admin_db).get_or_create( + name=role_name, + tenant=tenant, + defaults={ + "manage_users": False, + "manage_account": False, + "manage_billing": False, + "manage_providers": False, + "manage_integrations": False, + "manage_scans": False, + "unlimited_visibility": True, + }, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).filter( + user=user, + tenant_id=tenant.id, + ).delete() + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, + role=role, + tenant_id=tenant.id, + ) membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create( user=user, tenant=tenant, @@ -4545,15 +4562,19 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): @extend_schema_view( list=extend_schema( tags=["Compliance Overview"], - summary="List compliance overviews for a scan", - description="Retrieve an overview of all the compliance in a given scan.", + summary="List compliance overviews", + description=( + "Retrieve compliance overview data for a scan. When provider filters " + "are provided, the endpoint uses the latest completed scan for each " + "matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), ], responses={ @@ -4568,19 +4589,23 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Compliance overviews generation task failed" ), }, + filters=True, ), metadata=extend_schema( tags=["Compliance Overview"], summary="Retrieve metadata values from compliance overviews", - description="Fetch unique metadata values from a set of compliance overviews. This is useful for dynamic " - "filtering.", + description=( + "Fetch unique metadata values from compliance overviews. This is useful " + "for dynamic filtering. When provider filters are provided, metadata is " + "computed from the latest completed scan for each matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), ], responses={ @@ -4595,19 +4620,24 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Compliance overviews generation task failed" ), }, + filters=True, ), requirements=extend_schema( tags=["Compliance Overview"], - summary="List compliance requirements overview for a scan", - description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance " - "framework. This endpoint provides requirement-level details and aggregates status across regions.", + summary="List compliance requirements overview", + description=( + "Retrieve a detailed overview of compliance requirements, grouped by " + "compliance framework. This endpoint provides requirement-level details " + "and aggregates status across regions. When provider filters are provided, " + "the endpoint uses the latest completed scan for each matching provider." + ), parameters=[ OpenApiParameter( name="filter[scan_id]", - required=True, + required=False, type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Related scan ID.", + description="Related scan ID. Required unless a provider filter is provided.", ), OpenApiParameter( name="filter[compliance_id]", @@ -4644,6 +4674,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): location=OpenApiParameter.QUERY, description="Compliance framework ID to get attributes for.", ), + OpenApiParameter( + name="filter[scan_id]", + required=False, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Scan ID used to resolve the provider for " + "multi-provider universal frameworks (e.g. CSA CCM), so " + "the returned check IDs match the scan's provider. When omitted, " + "the first provider that declares the framework is used.", + ), ], responses={ 200: OpenApiResponse( @@ -4656,7 +4696,10 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): @method_decorator(CACHE_DECORATOR, name="list") @method_decorator(CACHE_DECORATOR, name="requirements") @method_decorator(CACHE_DECORATOR, name="attributes") -class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): +class ComplianceOverviewViewSet( + ProviderFilterParamsMixin, BaseRLSViewSet, TaskManagementMixin +): + jsonapi_filter_replace_dots = True pagination_class = ComplianceOverviewPagination queryset = ComplianceRequirementOverview.objects.all() serializer_class = ComplianceOverviewSerializer @@ -4670,28 +4713,22 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): required_permissions = [] def get_queryset(self): + if getattr(self, "swagger_fake_view", False): + return ComplianceRequirementOverview.objects.none() + role = get_role(self.request.user, self.request.tenant_id) unlimited_visibility = getattr( role, Permissions.UNLIMITED_VISIBILITY.value, False ) - if unlimited_visibility: - base_queryset = self.filter_queryset( - ComplianceRequirementOverview.objects.filter( - tenant_id=self.request.tenant_id - ) - ) - else: - providers = Provider.objects.filter( - provider_groups__in=role.provider_groups.all() - ).distinct() - base_queryset = self.filter_queryset( - ComplianceRequirementOverview.objects.filter( - tenant_id=self.request.tenant_id, scan__provider__in=providers - ) - ) + base_queryset = ComplianceRequirementOverview.objects.filter( + tenant_id=self.request.tenant_id + ) - return base_queryset + if unlimited_visibility: + return base_queryset + + return base_queryset.filter(scan__provider__in=get_providers(role)) def get_serializer_class(self): if hasattr(self, "response_serializer_class"): @@ -4729,6 +4766,72 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return summaries + def _validate_scan_selection(self, scan_id, has_provider_filters): + if scan_id and has_provider_filters: + raise ValidationError( + [ + { + "detail": "Use either filter[scan_id] or provider filters.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "invalid", + } + ] + ) + + if scan_id: + self._validate_uuid_filter_values("scan_id", [scan_id]) + return + + if has_provider_filters: + return + + raise ValidationError( + [ + { + "detail": "This query parameter is required unless a provider filter is provided.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "required", + } + ] + ) + + def _latest_scan_ids_for_provider_filters(self): + role = get_role(self.request.user, self.request.tenant_id) + scans = Scan.all_objects.filter( + tenant_id=self.request.tenant_id, + state=StateChoices.COMPLETED, + ) + + if not getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scans = scans.filter(provider__in=get_providers(role)) + + provider_filters = self._extract_provider_filters_from_params( + validate_uuids=True, + include_dot_aliases=True, + ) + if provider_filters: + scans = scans.filter(**provider_filters) + + return list( + scans.order_by("provider_id", "-inserted_at") + .distinct("provider_id") + .values_list("id", flat=True) + ) + + def _filtered_queryset_for_latest_provider_scans(self, latest_scan_ids=None): + if latest_scan_ids is None: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids) + # Provider filters stay on the filterset for OpenAPI docs, but runtime + # filtering happens on Scan first so compliance queries use scan IDs. + return self._apply_filterset( + queryset, + self.filterset_class, + exclude_keys=self.PROVIDER_FILTER_KEYS | {"scan_id"}, + ) + def _get_compliance_template(self, *, provider=None, scan_id=None): """Return the compliance template for the given provider or scan.""" if provider is None and scan_id is not None: @@ -4844,6 +4947,36 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): status=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + def _task_response_for_latest_provider_scans(self, latest_scan_ids): + for scan_id in latest_scan_ids: + task_response = self._task_response_if_running(str(scan_id)) + if task_response: + return task_response + return None + + def _latest_provider_scan_ids_without_data(self, latest_scan_ids): + data_presence_queryset = self.get_queryset().filter(scan_id__in=latest_scan_ids) + scan_ids_with_data = { + str(scan_id) + for scan_id in data_presence_queryset.values_list( + "scan_id", flat=True + ).distinct() + } + return [ + scan_id + for scan_id in latest_scan_ids + if str(scan_id) not in scan_ids_with_data + ] + + def _task_response_for_latest_provider_scans_without_data( + self, + latest_scan_ids, + ): + scan_ids_to_check = self._latest_provider_scan_ids_without_data( + latest_scan_ids, + ) + return self._task_response_for_latest_provider_scans(scan_ids_to_check) + def _list_with_region_filter(self, scan_id, region_filter): """ Fall back to detailed ComplianceRequirementOverview query when region filter is applied. @@ -4884,8 +5017,25 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): return Response(data) + def _list_with_latest_provider_filters(self): + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self._filtered_queryset_for_latest_provider_scans(latest_scan_ids) + data = self._aggregate_compliance_overview(queryset) + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + if task_response: + return task_response + + return Response(data) + def list(self, request, *args, **kwargs): scan_id = request.query_params.get("filter[scan_id]") + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) + self._validate_scan_selection(scan_id, has_provider_filters) + + if has_provider_filters: + return self._list_with_latest_provider_filters() # Specific scan requested - use optimized summaries with region support region_filter = request.query_params.get( @@ -4931,33 +5081,34 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="metadata") def metadata(self, request): scan_id = request.query_params.get("filter[scan_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) + self._validate_scan_selection(scan_id, has_provider_filters) + + latest_scan_ids = None + if has_provider_filters: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + queryset = self._filtered_queryset_for_latest_provider_scans( + latest_scan_ids ) + else: + queryset = self._apply_filterset(self.get_queryset(), self.filterset_class) + regions = list( - self.get_queryset() - .filter(scan_id=scan_id) - .values_list("region", flat=True) - .order_by("region") - .distinct() + queryset.values_list("region", flat=True).order_by("region").distinct() ) result = {"regions": regions} - if regions: - serializer = self.get_serializer(data=result) - serializer.is_valid(raise_exception=True) - return Response(serializer.data, status=status.HTTP_200_OK) + task_response = None + if has_provider_filters: + task_response = self._task_response_for_latest_provider_scans_without_data( + latest_scan_ids, + ) + elif not regions: + task_response = self._task_response_if_running(scan_id) + if task_response: + return task_response - task_response = self._task_response_if_running(scan_id) - if task_response: + if has_provider_filters and task_response: return task_response serializer = self.get_serializer(data=result) @@ -4967,19 +5118,10 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): @action(detail=False, methods=["get"], url_name="requirements") def requirements(self, request): scan_id = request.query_params.get("filter[scan_id]") + has_provider_filters = self._has_provider_filters(include_dot_aliases=True) compliance_id = request.query_params.get("filter[compliance_id]") - if not scan_id: - raise ValidationError( - [ - { - "detail": "This query parameter is required.", - "status": 400, - "source": {"pointer": "filter[scan_id]"}, - "code": "required", - } - ] - ) + self._validate_scan_selection(scan_id, has_provider_filters) if not compliance_id: raise ValidationError( @@ -4992,7 +5134,16 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): } ] ) - filtered_queryset = self.filter_queryset(self.get_queryset()) + latest_scan_ids = None + if has_provider_filters: + latest_scan_ids = self._latest_scan_ids_for_provider_filters() + filtered_queryset = self._filtered_queryset_for_latest_provider_scans( + latest_scan_ids + ) + else: + filtered_queryset = self._apply_filterset( + self.get_queryset(), self.filterset_class + ) all_requirements = filtered_queryset.values( "requirement_id", @@ -5051,13 +5202,22 @@ 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") @@ -5084,7 +5244,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): provider_type = None - # If we couldn't determine from database, try each provider type + # When a scan is provided, resolve the provider from it. Multi-provider + # universal frameworks (e.g. CSA CCM) share a single compliance_id + # across providers but expose different checks per provider, so the + # metadata (and therefore the check IDs the UI uses to fetch findings) + # must be returned for the scan's provider. Without this, the endpoint + # falls back to the first provider that declares the framework and + # returns its check IDs, leaving azure/gcp/... requirements with no + # matching findings. + scan_id = request.query_params.get("filter[scan_id]") + if "filter[scan_id]" in request.query_params: + # An explicit scan_id is authoritative: fail closed instead of + # falling back to another provider. Otherwise an invalid, empty + # (filter[scan_id]=) or inaccessible scan would silently return the + # first provider's check IDs, recreating the multi-provider mismatch + # this endpoint fixes. + if not scan_id: + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + # Tenant isolation is already enforced by Postgres RLS on the + # connection (see BaseRLSViewSet). Scope the lookup by provider + # group as well so a user with limited visibility can't resolve + # another provider's scan and read its compliance metadata, mirroring + # the RBAC scoping get_queryset() applies to the rest of the ViewSet. + role = get_role(request.user, request.tenant_id) + if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False): + scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id) + else: + scan_queryset = Scan.objects.filter(provider__in=get_providers(role)) + + try: + scan = scan_queryset.select_related("provider").get(id=scan_id) + except (Scan.DoesNotExist, DjangoValidationError, ValueError): + raise NotFound(detail=f"Scan '{scan_id}' not found.") + + provider_type = scan.provider.provider + if compliance_id not in get_compliance_frameworks(provider_type): + raise NotFound( + detail=( + f"Compliance framework '{compliance_id}' is not " + f"available for scan '{scan_id}'." + ) + ) + + # Fall back to the first provider that declares the framework. Keeps the + # endpoint working for provider-agnostic callers that omit the scan. if not provider_type: for pt in Provider.ProviderChoices.values: if compliance_id in get_compliance_frameworks(pt): @@ -5252,7 +5456,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): ), ) @method_decorator(CACHE_DECORATOR, name="list") -class OverviewViewSet(BaseRLSViewSet): +class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet): queryset = ScanSummary.objects.all() http_method_names = ["get"] ordering = ["-inserted_at"] @@ -5369,18 +5573,6 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id=tenant_id, scan_id__in=latest_scan_ids ) - def _normalize_jsonapi_params(self, query_params, exclude_keys=None): - """Convert JSON:API filter params (filter[X]) to flat params (X).""" - exclude_keys = exclude_keys or set() - normalized = QueryDict(mutable=True) - for key, values in query_params.lists(): - normalized_key = ( - key[7:-1] if key.startswith("filter[") and key.endswith("]") else key - ) - if normalized_key not in exclude_keys: - normalized.setlist(normalized_key, values) - return normalized - def _ensure_allowed_providers(self): """Populate allowed providers for RBAC-aware queries once per request.""" if getattr(self, "_providers_initialized", False): @@ -5400,15 +5592,6 @@ class OverviewViewSet(BaseRLSViewSet): return queryset.filter(**provider_filter) return queryset - def _apply_filterset(self, queryset, filterset_class, exclude_keys=None): - normalized_params = self._normalize_jsonapi_params( - self.request.query_params, exclude_keys=set(exclude_keys or []) - ) - filterset = filterset_class(normalized_params, queryset=queryset) - if not filterset.is_valid(): - raise ValidationError(filterset.errors) - return filterset.qs - def _latest_scan_ids_for_allowed_providers(self, tenant_id, provider_filters=None): provider_filter = self._get_provider_filter() queryset = Scan.all_objects.filter( @@ -5422,40 +5605,6 @@ class OverviewViewSet(BaseRLSViewSet): .values_list("id", flat=True) ) - def _extract_provider_filters_from_params(self): - """Extract and validate provider filters from query params.""" - params = self.request.query_params - filters = {} - valid_provider_types = {c[0] for c in Provider.ProviderChoices.choices} - - provider_id = params.get("filter[provider_id]") - if provider_id: - filters["provider_id"] = provider_id - - provider_id_in = params.get("filter[provider_id__in]") - if provider_id_in: - filters["provider_id__in"] = provider_id_in.split(",") - - provider_type = params.get("filter[provider_type]") - if provider_type: - if provider_type not in valid_provider_types: - raise ValidationError( - {"provider_type": f"Invalid choice: {provider_type}"} - ) - filters["provider__provider"] = provider_type - - provider_type_in = params.get("filter[provider_type__in]") - if provider_type_in: - types = provider_type_in.split(",") - invalid = [t for t in types if t not in valid_provider_types] - if invalid: - raise ValidationError( - {"provider_type__in": f"Invalid choices: {', '.join(invalid)}"} - ) - filters["provider__provider__in"] = types - - return filters - @action(detail=False, methods=["get"], url_name="providers") def providers(self, request): tenant_id = self.request.tenant_id @@ -5526,15 +5675,11 @@ class OverviewViewSet(BaseRLSViewSet): tenant_id = self.request.tenant_id providers_qs = Provider.objects.filter(tenant_id=tenant_id) + self._ensure_allowed_providers() if hasattr(self, "allowed_providers"): - allowed_ids = list(self.allowed_providers.values_list("id", flat=True)) - if not allowed_ids: - overview = [] - return Response( - self.get_serializer(overview, many=True).data, - status=status.HTTP_200_OK, - ) - providers_qs = providers_qs.filter(id__in=allowed_ids) + providers_qs = providers_qs.filter( + id__in=self.allowed_providers.values("id") + ) overview = ( providers_qs.values("provider") @@ -5750,29 +5895,41 @@ class OverviewViewSet(BaseRLSViewSet): description="Retrieve a specific snapshot by ID. If not provided, returns latest snapshots.", ), OpenApiParameter( - name="provider_id", + name="filter[provider_id]", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, description="Filter by specific provider ID", ), OpenApiParameter( - name="provider_id__in", + name="filter[provider_id__in]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by multiple provider IDs (comma-separated UUIDs)", ), OpenApiParameter( - name="provider_type", + name="filter[provider_type]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by provider type (aws, azure, gcp, etc.)", ), OpenApiParameter( - name="provider_type__in", + name="filter[provider_type__in]", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, description="Filter by multiple provider types (comma-separated)", ), + OpenApiParameter( + name="filter[provider_groups]", + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Filter by provider group ID", + ), + OpenApiParameter( + name="filter[provider_groups__in]", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Filter by multiple provider group IDs (comma-separated UUIDs)", + ), ], ) @action(detail=False, methods=["get"], url_name="threatscore") @@ -6114,6 +6271,8 @@ class OverviewViewSet(BaseRLSViewSet): "provider_id__in", "provider_type", "provider_type__in", + "provider_groups", + "provider_groups__in", } filtered_queryset = self._apply_filterset( base_queryset, CategoryOverviewFilter, exclude_keys=provider_filter_keys @@ -6183,6 +6342,8 @@ class OverviewViewSet(BaseRLSViewSet): "provider_id__in", "provider_type", "provider_type__in", + "provider_groups", + "provider_groups__in", } filtered_queryset = self._apply_filterset( base_queryset, @@ -7233,7 +7394,7 @@ SEVERITY_ORDER_REVERSE = {v: k for k, v in SEVERITY_ORDER.items()} ), retrieve=extend_schema(exclude=True), ) -class FindingGroupViewSet(BaseRLSViewSet): +class FindingGroupViewSet(JsonApiFilterMixin, BaseRLSViewSet): """ ViewSet for Finding Groups - aggregates findings by check_id. @@ -7249,6 +7410,7 @@ class FindingGroupViewSet(BaseRLSViewSet): queryset = FindingGroupDailySummary.objects.all() serializer_class = FindingGroupSerializer filterset_class = FindingGroupFilter + jsonapi_filter_replace_dots = True filter_backends = [ jsonapi_filters.QueryParameterValidationFilter, jsonapi_filters.OrderingFilter, @@ -7299,18 +7461,6 @@ class FindingGroupViewSet(BaseRLSViewSet): return queryset - def _normalize_jsonapi_params(self, query_params): - """Convert JSON:API filter params (filter[X]) to flat params (X).""" - normalized = QueryDict(mutable=True) - for key, values in query_params.lists(): - normalized_key = ( - key[7:-1] if key.startswith("filter[") and key.endswith("]") else key - ) - # Convert JSON:API dot notation to Django double underscore - normalized_key = normalized_key.replace(".", "__") - normalized.setlist(normalized_key, values) - return normalized - @extend_schema(exclude=True) def retrieve(self, request, *args, **kwargs): raise MethodNotAllowed(method="GET") @@ -8429,9 +8579,10 @@ class FindingGroupViewSet(BaseRLSViewSet): This endpoint returns finding groups without requiring date filters, automatically using the latest available data per check_id. - All other filters (provider_id, provider_type, check_id) are still supported. + Provider, provider group, check, and computed filters are still supported. """, tags=["Finding Groups"], + filters=True, ) @action(detail=False, methods=["get"], url_name="latest") def latest(self, request): diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 38cf047ac2..3308561b48 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,6 +45,7 @@ INSTALLED_APPS = [ "dj_rest_auth.registration", "rest_framework.authtoken", "drf_simple_apikey", + "django_eventstream", ] MIDDLEWARE = [ @@ -136,6 +138,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 a16c8de9a0..63472feec8 100644 --- a/api/src/backend/config/guniconf.py +++ b/api/src/backend/config/guniconf.py @@ -25,6 +25,37 @@ bind = f"{BIND_ADDRESS}:{PORT}" workers = env.int("DJANGO_WORKERS", default=multiprocessing.cpu_count() * 2 + 1) reload = DEBUG +# Native ASGI worker (gunicorn 24+). Required so SSE endpoints can keep the +# event loop alive while waiting for events. +worker_class = env("DJANGO_WORKER_CLASS", default="asgi") + +# Lifespan protocol. Django's ASGIHandler (config.asgi:application) serves only +# HTTP scopes and raises "Django can only handle ASGI/HTTP connections, not +# lifespan." gunicorn's default ("auto") probes the app with a lifespan scope +# to detect support, which triggers that error. We use no lifespan startup or +# shutdown hooks, so disable the protocol entirely. +asgi_lifespan = env("DJANGO_ASGI_LIFESPAN", default="off") + +# Event loop for the ASGI worker. "auto" uses uvloop when it is installed and +# falls back to the stdlib asyncio loop otherwise; uvloop gives the SSE event +# loop more headroom under many concurrent open streams. +asgi_loop = env("DJANGO_ASGI_LOOP", default="uvloop") + +# Max concurrent connections per ASGI worker. Each open SSE stream holds one +# connection for its whole lifetime, so this caps simultaneous SSE clients per +# worker (gunicorn's default is 1000). The sync-only `threads` option has no +# effect on ASGI workers. +worker_connections = env.int("DJANGO_WORKER_CONNECTIONS", default=1000) + +# Preload the application before forking workers in production: the app is +# imported once in the master and workers fork from it. In development, disable +# preload so the server restarts on code changes. +preload_app = not DEBUG + +# Worker timeout in seconds. Increased from the default 30s to handle requests +# that may take longer, such as complex API operations. +timeout = env.int("GUNICORN_TIMEOUT", default=120) + # Logging logconfig_dict = DJANGO_LOGGERS gunicorn_logger = logging.getLogger(BackendLogger.GUNICORN) 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/scan.py b/api/src/backend/tasks/jobs/scan.py index ded3ee57dc..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, @@ -357,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( @@ -1445,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 @@ -1459,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, @@ -1472,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} ) @@ -1554,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} ) @@ -1595,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, @@ -1640,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/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 6961659acf..130d8ae118 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -3674,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 @@ -3700,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 @@ -3719,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 @@ -3751,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" @@ -3765,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 @@ -3778,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, @@ -3796,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 @@ -3828,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() @@ -3850,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 @@ -3882,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 @@ -3890,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 @@ -3914,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..f96318c1f4 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -16,7 +16,7 @@ constraints = [ { name = "aiobotocore", specifier = "==2.25.1" }, { name = "aiofiles", specifier = "==24.1.0" }, { name = "aiohappyeyeballs", specifier = "==2.6.1" }, - { name = "aiohttp", specifier = "==3.13.5" }, + { name = "aiohttp", specifier = "==3.14.0" }, { name = "aioitertools", specifier = "==0.13.0" }, { name = "aiosignal", specifier = "==1.4.0" }, { name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" }, @@ -61,9 +61,8 @@ constraints = [ { name = "astroid", specifier = "==3.2.4" }, { name = "async-timeout", specifier = "==5.0.1" }, { name = "attrs", specifier = "==25.4.0" }, - { name = "authlib", specifier = "==1.6.9" }, + { name = "authlib", specifier = "==1.6.12" }, { name = "autopep8", specifier = "==2.3.2" }, - { name = "awsipranges", specifier = "==0.3.3" }, { name = "azure-cli-core", specifier = "==2.83.0" }, { name = "azure-cli-telemetry", specifier = "==1.1.0" }, { name = "azure-common", specifier = "==1.1.28" }, @@ -146,6 +145,7 @@ constraints = [ { name = "django-celery-results", specifier = "==2.6.0" }, { name = "django-cors-headers", specifier = "==4.4.0" }, { name = "django-environ", specifier = "==0.11.2" }, + { name = "django-eventstream", specifier = "==5.3.3" }, { name = "django-filter", specifier = "==24.3" }, { name = "django-guid", specifier = "==3.5.0" }, { name = "django-postgres-extra", specifier = "==2.0.9" }, @@ -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,7 @@ constraints = [ { name = "uritemplate", specifier = "==4.2.0" }, { name = "urllib3", specifier = "==2.7.0" }, { name = "uuid6", specifier = "==2024.7.10" }, + { name = "uvloop", specifier = "==0.22.1" }, { name = "vine", specifier = "==5.1.0" }, { name = "vulture", specifier = "==2.14" }, { name = "wcwidth", specifier = "==0.5.3" }, @@ -469,7 +470,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -478,44 +479,47 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, - { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, - { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, - { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, - { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, - { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, - { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, - { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" }, + { url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" }, + { url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" }, + { url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, + { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, ] [[package]] @@ -1045,15 +1049,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, ] -[[package]] -name = "awsipranges" -version = "0.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/2e/6efa95f995369da828715f41705686cd214b9259ed758266942553d40441/awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0", size = 16739, upload-time = "2022-02-10T21:08:32.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ce/5c9a8bf91bdc9592a409c99e58fd99f2727ab8d634719c0ad796021b76d7/awsipranges-0.3.3-py3-none-any.whl", hash = "sha256:f3d7a54aeaf7fe310beb5d377a4034a63a51b72677ae6af3e0967bc4de7eedaf", size = 18106, upload-time = "2022-02-10T21:08:31.497Z" }, -] - [[package]] name = "azure-cli-core" version = "2.83.0" @@ -2362,6 +2357,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f1/468b49cccba3b42dda571063a14c668bb0b53a1d5712426d18e36663bd53/django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05", size = 19141, upload-time = "2023-09-01T21:02:59.88Z" }, ] +[[package]] +name = "django-eventstream" +version = "5.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-grip" }, + { name = "gripcontrol" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/49/ec6cbc24f3f30465370df7096cfea9722bad2b0c1f35a7ff5d45fb96cff6/django_eventstream-5.3.3.tar.gz", hash = "sha256:6880b03298eebf18c1b736b972fb862eaf631dfbb79f8b27496418a3495d08dc", size = 47622, upload-time = "2025-10-23T00:22:40.291Z" } + [[package]] name = "django-filter" version = "24.3" @@ -2374,6 +2382,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" }, ] +[[package]] +name = "django-grip" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "gripcontrol" }, + { name = "pubcontrol" }, + { name = "six" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/d0/2c7b04fa864073cd8cb324f8674672162282d97540d56732cbd3a9ae5bca/django-grip-3.5.2.tar.gz", hash = "sha256:1ee1601492cd110256bd03e4a68797a9fbefa27c15f5a838bf245df97db0450c", size = 7626, upload-time = "2025-03-24T18:53:58.677Z" } + [[package]] name = "django-guid" version = "3.5.0" @@ -2985,6 +3006,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, ] +[[package]] +name = "gripcontrol" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pubcontrol" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/51/1cbf88384fbe97a1454fb0adddcdca8cb90ceb99c3250274c334db844f4f/gripcontrol-4.4.0.tar.gz", hash = "sha256:44ee6fe244a02870aa4e5bc810138ccaf5070dce5eb149b8ee9e27b960a95c2d", size = 12526, upload-time = "2026-05-14T21:19:28.49Z" } + [[package]] name = "grpc-google-iam-v1" version = "0.14.3" @@ -3046,14 +3078,14 @@ wheels = [ [[package]] name = "gunicorn" -version = "23.0.0" +version = "26.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, ] [[package]] @@ -3155,20 +3187,20 @@ wheels = [ [[package]] name = "iamdata" -version = "0.1.202602021" +version = "0.1.202605131" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/5e/8179963b7a528c548824a8e4088150509d9fa8571dd622b7399f6d2d5680/iamdata-0.1.202602021.tar.gz", hash = "sha256:c24265fc3694076f65da91a8aa9361b60da25f7b8cfd8ba4ddd6aa1b9bb5153e", size = 771233, upload-time = "2026-02-02T05:49:56.76Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/ea/d68e25aa4392e8a9f8e6523adc95a5fb86baf98d052efa2cec4d4a00e7ce/iamdata-0.1.202605131.tar.gz", hash = "sha256:ab4e8f1ea080394157848fecd0ca643633e35b2e0cb1965c9ed9bdd673afe00c", size = 793465, upload-time = "2026-05-13T05:57:10.607Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/9e/ae7a3019aa5a27d70412b74da4f0304695efa5d9a88f0689f37ea2602ea2/iamdata-0.1.202602021-py3-none-any.whl", hash = "sha256:48419662d75dd0e1ea22b9cc98fd70201d4c72760c6897acc46ad9ab90633d18", size = 1226614, upload-time = "2026-02-02T05:49:54.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/93/03396b477b0faa9f1a12142209b59aa13d0fe4f64e2be47883f607789c14/iamdata-0.1.202605131-py3-none-any.whl", hash = "sha256:350e317d96fb8c8ddf30aa6da222788d302af5f13c9e357b59f9eefe50b8af5a", size = 1259166, upload-time = "2026-05-13T05:57:09.093Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -3971,30 +4003,30 @@ wheels = [ [[package]] name = "numpy" -version = "2.0.2" +version = "2.2.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, - { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, - { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, - { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, - { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, - { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, - { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, - { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, - { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, ] [[package]] @@ -4415,8 +4447,8 @@ wheels = [ [[package]] name = "prowler" -version = "5.27.0" -source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" } +version = "5.31.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#b5bb85c9564f6ca6a7f66c851bb56bde719205ee" } dependencies = [ { name = "alibabacloud-actiontrail20200706" }, { name = "alibabacloud-credentials" }, @@ -4432,7 +4464,6 @@ dependencies = [ { name = "alibabacloud-tea-openapi" }, { name = "alibabacloud-vpc20160428" }, { name = "alive-progress" }, - { name = "awsipranges" }, { name = "azure-identity" }, { name = "azure-keyvault-keys" }, { name = "azure-mgmt-apimanagement" }, @@ -4489,9 +4520,14 @@ dependencies = [ { name = "pygithub" }, { name = "python-dateutil" }, { name = "pytz" }, + { name = "scaleway" }, { name = "schema" }, { name = "shodan" }, { name = "slack-sdk" }, + { name = "stackit-core" }, + { name = "stackit-iaas" }, + { name = "stackit-objectstorage" }, + { name = "stackit-resourcemanager" }, { name = "tabulate" }, { name = "tzlocal" }, { name = "uuid6" }, @@ -4499,7 +4535,7 @@ dependencies = [ [[package]] name = "prowler-api" -version = "1.31.0" +version = "1.32.0" source = { virtual = "." } dependencies = [ { name = "cartography" }, @@ -4512,6 +4548,7 @@ dependencies = [ { name = "django-celery-results" }, { name = "django-cors-headers" }, { name = "django-environ" }, + { name = "django-eventstream" }, { name = "django-filter" }, { name = "django-guid" }, { name = "django-postgres-extra" }, @@ -4538,6 +4575,7 @@ dependencies = [ { name = "sentry-sdk", extra = ["django"] }, { name = "sqlparse" }, { name = "uuid6" }, + { name = "uvloop" }, { name = "werkzeug" }, { name = "xmlsec" }, ] @@ -4576,6 +4614,7 @@ requires-dist = [ { name = "django-celery-results", specifier = "==2.6.0" }, { name = "django-cors-headers", specifier = "==4.4.0" }, { name = "django-environ", specifier = "==0.11.2" }, + { name = "django-eventstream", specifier = "==5.3.3" }, { name = "django-filter", specifier = "==24.3" }, { name = "django-guid", specifier = "==3.5.0" }, { name = "django-postgres-extra", specifier = "==2.0.9" }, @@ -4588,7 +4627,7 @@ requires-dist = [ { name = "drf-spectacular-jsonapi", specifier = "==0.5.1" }, { name = "fonttools", specifier = "==4.62.1" }, { name = "gevent", specifier = "==25.9.1" }, - { name = "gunicorn", specifier = "==23.0.0" }, + { name = "gunicorn", specifier = "==26.0.0" }, { name = "h2", specifier = "==4.3.0" }, { name = "lxml", specifier = "==6.1.0" }, { name = "markdown", specifier = "==3.10.2" }, @@ -4602,6 +4641,7 @@ requires-dist = [ { name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" }, { name = "sqlparse", specifier = "==0.5.5" }, { name = "uuid6", specifier = "==2024.7.10" }, + { name = "uvloop", specifier = "==0.22.1" }, { name = "werkzeug", specifier = "==3.1.7" }, { name = "xmlsec", specifier = "==1.3.17" }, ] @@ -4676,6 +4716,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/08/9c66c269b0d417a0af9fb969535f0371b8c538633535a7a6a5ca3f9231e2/psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", size = 1163864, upload-time = "2023-10-28T09:37:28.155Z" }, ] +[[package]] +name = "pubcontrol" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/6a/02202a247214a6ffd5148ab1b16aca1c334b40dca211bca0442c8b7c7447/pubcontrol-3.5.0.tar.gz", hash = "sha256:a5ec6b3f53edfd005675518e5e4cc23b34122776835ae7c6dbd1db173d1ff0cb", size = 18199, upload-time = "2023-07-05T19:11:40.477Z" } + [[package]] name = "py-deviceid" version = "0.1.1" @@ -4687,14 +4737,14 @@ wheels = [ [[package]] name = "py-iam-expand" -version = "0.1.0" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "iamdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/99/8d31a30b37825577275bb3663885b55075fba80257fcd6813b85d3aaffa8/py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96", size = 10228, upload-time = "2025-04-30T07:15:35.304Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/08/f6e11a029b81f0bec4b7b1f18704aadf509a882cc386c90ef1ac043c18cc/py_iam_expand-0.3.0.tar.gz", hash = "sha256:4ccfe25f40ba0633a152c4f86b49cde8972ee3d4b6009b017a4310cc4b9e64c7", size = 10234, upload-time = "2026-02-24T09:47:47.772Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/19/482c2e0768cda7afaed07918e4fbd951e2418255fb5d1d9b35b284871716/py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510", size = 12522, upload-time = "2025-04-30T07:15:33.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dd/4056d0bc3d6317039d2dd2ee7cd6a5389575603e270399a8f9f20e11e721/py_iam_expand-0.3.0-py3-none-any.whl", hash = "sha256:94c0a1e9dd60316ce60ddc0cdc9a046119bde335b5bb9593ee29224857860d5a", size = 12527, upload-time = "2026-02-24T09:47:45.602Z" }, ] [[package]] @@ -5531,6 +5581,67 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "stackit-core" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" }, +] + +[[package]] +name = "stackit-iaas" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" }, +] + +[[package]] +name = "stackit-objectstorage" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" }, +] + +[[package]] +name = "stackit-resourcemanager" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "stackit-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" }, +] + [[package]] name = "statsd" version = "4.0.1" @@ -5735,6 +5846,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/3e/4ae6af487ce5781ed71d5fe10aca72e7cbc4d4f45afc31b120287082a8dd/uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7", size = 6376, upload-time = "2024-07-10T16:39:36.148Z" }, ] +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, +] + [[package]] name = "vine" version = "5.1.0" diff --git a/contrib/aws/multi-account-securityhub/Dockerfile b/contrib/aws/multi-account-securityhub/Dockerfile index 1cc6c326b7..de5c57ba7b 100644 --- a/contrib/aws/multi-account-securityhub/Dockerfile +++ b/contrib/aws/multi-account-securityhub/Dockerfile @@ -1,7 +1,7 @@ # Build command # docker build --platform=linux/amd64 --no-cache -t prowler:latest . -ARG PROWLER_VERSION=latest +ARG PROWLER_VERSION=latest@sha256:4b796c6df40a3350c7947747b59bdda230d0da6222287500e13b0a8e1574aad4 FROM toniblyx/prowler:${PROWLER_VERSION} diff --git a/contrib/inventory-graph/lib/inventory_output.py b/contrib/inventory-graph/lib/inventory_output.py index 359e7eb91f..459dc37cdb 100644 --- a/contrib/inventory-graph/lib/inventory_output.py +++ b/contrib/inventory-graph/lib/inventory_output.py @@ -16,7 +16,6 @@ from typing import Optional from prowler.lib.logger import logger from lib.models import ConnectivityGraph - # --------------------------------------------------------------------------- # JSON output # --------------------------------------------------------------------------- diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index 81eda5f690..0395c6f704 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -28,7 +28,7 @@ containers: image: repository: prowlercloud/prowler-api pullPolicy: IfNotPresent - command: ["../docker-entrypoint.sh", "beat"] + command: ["/home/prowler/docker-entrypoint.sh", "beat"] secrets: POSTGRES_HOST: diff --git a/contrib/k8s/helm/prowler-app/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/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/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/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..51c7e739b0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -395,7 +395,8 @@ "developer-guide/lighthouse-architecture", "developer-guide/mcp-server", "developer-guide/ai-skills", - "developer-guide/prowler-studio" + "developer-guide/prowler-studio", + "developer-guide/server-sent-events" ] }, { 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/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/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/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index cd6db78f37..ada7536290 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,53 @@ 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) +- `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) +- `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) +- `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) + +### 🔄 Changed + +- Replaced the unmaintained `awsipranges` dependency with a small standard-library helper for the `route53_dangling_ip_subdomain_takeover` check [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) + +### 🔐 Security + +- `pytest` from 8.3.5 to 9.0.3, patching a known vulnerability in the SDK test dependency [(#11291)](https://github.com/prowler-cloud/prowler/pull/11291) +- `black` from 25.1.0 to 26.3.1, patching a known vulnerability in the SDK formatter dependency [(#11290)](https://github.com/prowler-cloud/prowler/pull/11290) +- `microsoft-kiota-*` to 1.9.9 and `aiohttp` to 3.14.0, patching known CVEs [(#11596)](https://github.com/prowler-cloud/prowler/pull/11596) +- Container base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` bumped to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs); `.trivyignore` documents remaining bookworm criticals with no-fix or not-affected rationale [(#11592)](https://github.com/prowler-cloud/prowler/pull/11592) + +--- + +## [5.30.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 @@ -30,6 +76,11 @@ All notable changes to the **Prowler SDK** are documented in this file. - 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) --- @@ -42,10 +93,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/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 332d081590..3b1184e0e5 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -438,7 +438,8 @@ "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" ] }, { diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index 0051795890..1eb0f75792 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -1266,7 +1266,9 @@ "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" + ] }, { "Id": "A.8.14", @@ -1293,7 +1295,8 @@ "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" ] }, { @@ -1407,6 +1410,8 @@ "aks_network_policy_enabled", "containerregistry_not_publicly_accessible", "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_public_network_access_disabled", + "databricks_workspace_public_network_access_disabled", "network_bastion_host_exists", "network_flow_log_captured_sent", "network_flow_log_more_than_90_days", @@ -1500,6 +1505,7 @@ ], "Checks": [ "app_minimum_tls_version_12", + "cosmosdb_account_minimum_tls_version", "monitor_storage_account_with_activity_logs_cmk_encrypted", "sqlserver_tde_encrypted_with_cmk", "sqlserver_tde_encryption_enabled", diff --git a/prowler/compliance/csa_ccm_4.0.json b/prowler/compliance/csa_ccm_4.0.json index e014c5f408..57f1a67758 100644 --- a/prowler/compliance/csa_ccm_4.0.json +++ b/prowler/compliance/csa_ccm_4.0.json @@ -1087,7 +1087,9 @@ "storage_blob_versioning_is_enabled", "storage_geo_redundant_enabled", "vm_scaleset_associated_with_load_balancer", - "vm_scaleset_not_empty" + "vm_scaleset_not_empty", + "cosmosdb_account_automatic_failover_enabled", + "cosmosdb_account_backup_policy_continuous" ], "gcp": [ "compute_instance_automatic_restart_enabled", diff --git a/prowler/compliance/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 71c59d82a3..9e2b079da6 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" diff --git a/prowler/lib/banner.py b/prowler/lib/banner.py index bb693fb961..8115983bc6 100644 --- a/prowler/lib/banner.py +++ b/prowler/lib/banner.py @@ -28,15 +28,13 @@ def print_banner(legend: bool = False, provider: str = None): 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): @@ -56,18 +54,17 @@ def print_prowler_cloud_banner(provider: str = 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} + 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}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels -{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, prioritization and remediation -{bar} {check} {Style.BRIGHT}Organizations{Style.RESET_ALL} - all your AWS accounts under one organization -{bar} {check} {Style.BRIGHT}Continuous scanning{Style.RESET_ALL} - scheduled scans with history, trends and alerts -{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC -{bar} {check} {Style.BRIGHT}Reports{Style.RESET_ALL} - download ready-to-share PDF reports -{bar} {check} {Style.BRIGHT}Live compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date +{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} -""" - ) +{bar} {Fore.BLUE}Start free at 👉 cloud.prowler.com{Style.RESET_ALL} +""") diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 8f05fc30cb..2848e9c788 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, @@ -166,13 +168,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/html/html.py b/prowler/lib/outputs/html/html.py index 52afd071ae..545bcbc456 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -73,8 +73,7 @@ class HTML(Output): elif finding.status == "FAIL": row_class = "table-danger" - self._data.append( - f""" + self._data.append(f""" {finding_status} {finding.metadata.Severity.value} @@ -89,8 +88,7 @@ class HTML(Output):

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

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

- """ - ) + """) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -143,8 +141,7 @@ class HTML(Output): from_cli (bool): whether the request is from the CLI or not """ try: - file_descriptor.write( - f""" + file_descriptor.write(f""" @@ -253,8 +250,7 @@ class HTML(Output): Compliance - """ - ) + """) except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" @@ -269,8 +265,7 @@ class HTML(Output): file_descriptor (file): the file descriptor to write the footer """ try: - file_descriptor.write( - """ + file_descriptor.write(""" @@ -409,8 +404,7 @@ class HTML(Output): -""" - ) +""") except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" diff --git a/prowler/lib/outputs/jira/jira.py b/prowler/lib/outputs/jira/jira.py index f7f31666e1..f826d09026 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 = 30 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/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 2e451910fb..0874d14a07 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -2582,6 +2582,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2591,6 +2592,9 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-6", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -2604,6 +2608,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -7344,6 +7349,7 @@ "lightsail": { "regions": { "aws": [ + "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-south-1", @@ -7354,9 +7360,11 @@ "ca-central-1", "eu-central-1", "eu-north-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -8269,7 +8277,9 @@ "cn-north-1", "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -9220,6 +9230,7 @@ "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -9986,6 +9997,8 @@ "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "eu-central-1", "eu-south-2", diff --git a/prowler/providers/aws/lib/ip_ranges/__init__.py b/prowler/providers/aws/lib/ip_ranges/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/lib/ip_ranges/ip_ranges.py b/prowler/providers/aws/lib/ip_ranges/ip_ranges.py new file mode 100644 index 0000000000..3dbb9d1b17 --- /dev/null +++ b/prowler/providers/aws/lib/ip_ranges/ip_ranges.py @@ -0,0 +1,51 @@ +import json +import urllib.error +import urllib.request +from ipaddress import ip_network + +from prowler.lib.logger import logger + +AWS_IP_RANGES_URL = "https://ip-ranges.amazonaws.com/ip-ranges.json" +AWS_IP_RANGES_TIMEOUT = 10 + + +def get_public_ip_networks() -> list: + """Fetch the AWS public IP prefixes as a list of ip_network objects. + + The request verifies the server certificate against the system trust store, + matching urllib's default behaviour. This replaces the unmaintained + awsipranges package, whose latest release (0.3.3) calls + urllib.request.urlopen() with the cafile/capath arguments that Python 3.13 + removed. + + Returns an empty list when the feed cannot be fetched or parsed, and skips + individual malformed prefixes, so a transient or corrupt feed never aborts + the calling check. + """ + try: + with urllib.request.urlopen( + AWS_IP_RANGES_URL, timeout=AWS_IP_RANGES_TIMEOUT + ) as response: + ranges = json.loads(response.read()) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return [] + + networks = [] + for key, prefixes in ( + ("ip_prefix", ranges.get("prefixes", [])), + ("ipv6_prefix", ranges.get("ipv6_prefixes", [])), + ): + for prefix in prefixes: + cidr = prefix.get(key) + if not cidr: + continue + try: + networks.append(ip_network(cidr)) + except ValueError as error: + logger.warning( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return networks diff --git a/prowler/providers/aws/services/bedrock/bedrock_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/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/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/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_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..1e30452294 100644 --- a/prowler/providers/azure/services/databricks/databricks_service.py +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -66,6 +66,9 @@ class Databricks(AzureService): id=workspace.id, name=workspace.name, location=workspace.location, + public_network_access=getattr( + workspace, "public_network_access", None + ), custom_managed_vnet_id=( getattr( workspace_parameters, "custom_virtual_network_id", None @@ -107,6 +110,7 @@ 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. 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 +118,6 @@ class DatabricksWorkspace(BaseModel): id: str name: str location: str + public_network_access: Optional[str] = None custom_managed_vnet_id: Optional[str] = None managed_disk_encryption: Optional[ManagedDiskEncryption] = None 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/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..77ae9180a3 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 ) @@ -693,30 +676,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..46866b350d --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudfunction.cloudfunction_client import ( + cloudfunction_client, +) + + +class cloudfunction_function_inside_vpc(Check): + """Check that Cloud Functions are attached to a Serverless VPC Access connector. + + Verifies that each active Cloud Function has a `vpcConnector` configured so + egress traffic flows through a private VPC network instead of the public + internet. Functions in non-`ACTIVE` states are skipped because their network + configuration is transient. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the VPC-connector check across all Cloud Functions. + + Returns: + A list of `Check_Report_GCP` findings, one per active Cloud + Function. Status is `PASS` when a `vpc_connector` is set and `FAIL` + otherwise. + """ + findings = [] + for function in cloudfunction_client.functions: + if function.state != "ACTIVE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=function) + if function.vpc_connector: + report.status = "PASS" + report.status_extended = ( + f"Cloud Function {function.name} is connected to a VPC via " + f"connector: {function.vpc_connector}." + ) + else: + report.status = "FAIL" + report.status_extended = f"Cloud Function {function.name} is not connected to any VPC network." + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py new file mode 100644 index 0000000000..d1043151b7 --- /dev/null +++ b/prowler/providers/gcp/services/cloudfunction/cloudfunction_service.py @@ -0,0 +1,84 @@ +from typing import Optional + +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class CloudFunction(GCPService): + """Cloud Functions v2 service client. + + Enumerates Cloud Functions across every accessible project and region + using the `cloudfunctions.googleapis.com` v2 API and exposes them through + the `functions` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload Cloud Functions.""" + super().__init__("cloudfunctions", provider, api_version="v2") + self.functions = [] + self._get_functions() + + def _get_functions(self) -> None: + """Fetch Cloud Functions for every project and location.""" + for project_id in self.project_ids: + try: + locations = self.client.projects().locations() + locations_request = locations.list(name=f"projects/{project_id}") + while locations_request is not None: + locations_response = locations_request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for location in locations_response.get("locations", []): + location_id = location["locationId"] + try: + functions = locations.functions() + request = functions.list( + parent=f"projects/{project_id}/locations/{location_id}" + ) + while request is not None: + response = request.execute( + num_retries=DEFAULT_RETRY_ATTEMPTS + ) + for fn in response.get("functions", []): + service_config = fn.get("serviceConfig", {}) + self.functions.append( + Function( + name=fn["name"].split("/")[-1], + project_id=project_id, + location=location_id, + state=fn.get("state", "UNKNOWN"), + vpc_connector=service_config.get( + "vpcConnector" + ), + ) + ) + request = functions.list_next( + previous_request=request, + previous_response=response, + ) + except Exception as error: + logger.error( + f"{location_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + locations_request = locations.list_next( + previous_request=locations_request, + previous_response=locations_response, + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Function(BaseModel): + """Cloud Function resource consumed by GCP checks.""" + + name: str + project_id: str + location: str + state: str + vpc_connector: Optional[str] = None diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..3e66f9cdd3 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "cloudsql_instance_high_availability_enabled", + "CheckTitle": "Cloud SQL instance has high availability (REGIONAL) configured", + "CheckType": [], + "ServiceName": "cloudsql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "sqladmin.googleapis.com/Instance", + "Description": "Ensures that Cloud SQL instances have high availability configured by setting availabilityType to REGIONAL. A REGIONAL instance maintains a standby replica in a different zone within the same region and automatically fails over on zone-level outages.", + "Risk": "Instances with ZONAL availability have no standby replica. A zone-level outage will cause database downtime until manual recovery, violating availability requirements and potentially breaching SLAs and ISMS-P 2.12.1 disaster preparedness controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/sql/docs/postgres/high-availability", + "https://cloud.google.com/sql/docs/sqlserver/high-availability" + ], + "Remediation": { + "Code": { + "CLI": "gcloud sql instances patch --availability-type=REGIONAL", + "NativeIaC": "", + "Other": "1. Go to Google Cloud Console > SQL > Instances.\n2. Click the instance name, then Edit.\n3. Under Availability, select Multiple zones (Highly available).\n4. Click Save.", + "Terraform": "```hcl\nresource \"google_sql_database_instance\" \"example\" {\n name = \"\"\n database_version = \"POSTGRES_15\"\n region = \"\"\n\n settings {\n tier = \"db-custom-2-7680\"\n\n availability_type = \"REGIONAL\" # Critical: enables HA standby replica\n\n backup_configuration {\n enabled = true\n start_time = \"02:00\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set availabilityType to REGIONAL for all production Cloud SQL instances. This creates a standby replica in a different zone and enables automatic failover, reducing RTO in the event of a zone outage.", + "Url": "https://hub.prowler.com/check/cloudsql_instance_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudsql_instance_automated_backups" + ], + "Notes": "Enabling HA increases instance cost approximately 2x due to the standby replica. ZONAL instances are acceptable for non-production workloads where downtime is tolerable." +} diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py new file mode 100644 index 0000000000..37bf576345 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudsql.cloudsql_client import cloudsql_client + + +class cloudsql_instance_high_availability_enabled(Check): + """Check that Cloud SQL primary instances are configured for high availability. + + Verifies that each Cloud SQL primary instance has `availabilityType` set to + `REGIONAL`, which provisions a standby replica in a different zone within + the same region and enables automatic failover on zone-level outages. Read + replicas are skipped because they inherit availability from their primary. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the high availability check across all Cloud SQL instances. + + Returns: + A list of `Check_Report_GCP` findings, one per Cloud SQL primary + instance. Status is `PASS` when `availability_type == "REGIONAL"` + and `FAIL` otherwise. + """ + findings = [] + for instance in cloudsql_client.instances: + if instance.instance_type != "CLOUD_SQL_INSTANCE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + if instance.availability_type == "REGIONAL": + report.status = "PASS" + report.status_extended = ( + f"Database instance {instance.name} has high availability " + f"(REGIONAL) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Database instance {instance.name} does not have high " + f"availability configured (current: " + f"{instance.availability_type})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py index 137169999a..1fe706bb02 100644 --- a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py @@ -46,6 +46,9 @@ class CloudSQL(GCPService): "authorizedNetworks", [] ), flags=settings.get("databaseFlags", []), + availability_type=settings.get( + "availabilityType", "ZONAL" + ), instance_type=instance.get( "instanceType", "CLOUD_SQL_INSTANCE" ), @@ -76,6 +79,7 @@ class Instance(BaseModel): ssl_mode: str automated_backups: bool flags: list + availability_type: str = "ZONAL" instance_type: str = "CLOUD_SQL_INSTANCE" cmek_key_name: Optional[str] = None project_id: str diff --git a/prowler/providers/gcp/services/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/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/pyproject.toml b/pyproject.toml index 28a4b531cc..f3cec52678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = ["hatchling"] [dependency-groups] dev = [ "bandit==1.8.3", - "black==25.1.0", + "black==26.3.1", "coverage==7.6.12", "docker==7.1.0", "filelock==3.20.3", @@ -17,7 +17,7 @@ dev = [ "openapi-spec-validator==0.7.1", "prek==0.3.9", "pylint==3.3.4", - "pytest==8.3.5", + "pytest==9.0.3", "pytest-cov==6.0.0", "pytest-env==1.1.5", "pytest-randomly==3.16.0", @@ -32,10 +32,10 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13" ] dependencies = [ - "awsipranges==0.3.3", "alive-progress==3.3.0", "azure-identity==1.21.0", "azure-keyvault-keys==4.10.0", @@ -79,9 +79,9 @@ dependencies = [ "jsonschema==4.23.0", "kubernetes==32.0.1", "markdown==3.10.2", - "microsoft-kiota-abstractions==1.9.2", + "microsoft-kiota-abstractions==1.9.9", + "numpy==2.2.6", "msgraph-sdk==1.55.0", - "numpy==2.0.2", "okta==3.4.2", "openstacksdk==4.2.0", "pandas==2.2.3", @@ -100,7 +100,7 @@ dependencies = [ "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", @@ -123,8 +123,8 @@ license = "Apache-2.0" maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] name = "prowler" readme = "README.md" -requires-python = ">=3.10,<3.13" -version = "5.30.0" +requires-python = ">=3.10,<3.14" +version = "5.31.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -162,7 +162,7 @@ constraint-dependencies = [ "aenum==3.1.17", "aiofiles==24.1.0", "aiohappyeyeballs==2.6.1", - "aiohttp==3.13.5", + "aiohttp==3.14.0", "aiosignal==1.4.0", "alibabacloud-actiontrail20200706==2.4.1", "alibabacloud-credentials==1.0.3", @@ -205,7 +205,7 @@ constraint-dependencies = [ "azure-core==1.41.0", "azure-mgmt-core==1.6.0", "bandit==1.8.3", - "black==25.1.0", + "black==26.3.1", "blinker==1.9.0", "certifi==2026.4.22", "cffi==2.0.0", @@ -267,12 +267,12 @@ constraint-dependencies = [ "markupsafe==3.0.3", "mccabe==0.7.0", "mdurl==0.1.2", - "microsoft-kiota-authentication-azure==1.9.2", - "microsoft-kiota-http==1.9.2", - "microsoft-kiota-serialization-form==1.9.2", - "microsoft-kiota-serialization-json==1.9.2", - "microsoft-kiota-serialization-multipart==1.9.2", - "microsoft-kiota-serialization-text==1.9.2", + "microsoft-kiota-authentication-azure==1.9.9", + "microsoft-kiota-http==1.9.9", + "microsoft-kiota-serialization-form==1.9.9", + "microsoft-kiota-serialization-json==1.9.9", + "microsoft-kiota-serialization-multipart==1.9.9", + "microsoft-kiota-serialization-text==1.9.9", "mock==5.2.0", "moto==5.1.11", "mpmath==1.3.0", @@ -320,11 +320,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", @@ -364,3 +365,13 @@ constraint-dependencies = [ "zstd==1.5.7.3" ] override-dependencies = ["okta==3.4.2"] + +[tool.vulture] +# Suppress known false positives. The CI command only passes --exclude and +# --min-confidence on the CLI, so ignore_names from here still applies (vulture +# only overrides the keys set on the CLI). +# - mock_* : pytest fixtures injected as test params but unused in the body +# (e.g. mock_sensitive_args in tests/lib/cli/redact_test.py) +# - view : DRF BasePermission.has_object_permission(self, request, view, obj) +# framework-required signature param in skills/django-drf template assets +ignore_names = ["mock_*", "view"] diff --git a/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/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/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/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/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/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/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..5c743d1891 100644 --- a/tests/providers/azure/services/databricks/databricks_service_test.py +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -18,6 +18,7 @@ def mock_databricks_get_workspaces(_): id="test-workspace-id", name="test-workspace", location="eastus", + public_network_access="Disabled", custom_managed_vnet_id="test-vnet-id", managed_disk_encryption=ManagedDiskEncryption( key_name="test-key", @@ -53,6 +54,7 @@ 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.custom_managed_vnet_id == "test-vnet-id" assert ( workspace.managed_disk_encryption.__class__.__name__ 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/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..b764760538 --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_function_inside_vpc/cloudfunction_function_inside_vpc_test.py @@ -0,0 +1,197 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + GCP_US_CENTER1_LOCATION, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.cloudfunction." + "cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc" +) +_CLIENT_PATH = f"{_CHECK_PATH}.cloudfunction_client" + + +class Test_cloudfunction_function_inside_vpc: + def test_no_functions(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + + cloudfunction_client.functions = [] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 + + def test_function_with_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + connector = ( + f"projects/{GCP_PROJECT_ID}/locations/{GCP_US_CENTER1_LOCATION}" + f"/connectors/my-connector" + ) + cloudfunction_client.functions = [ + Function( + name="fn-vpc", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=connector, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Cloud Function fn-vpc is connected to a VPC via connector: {connector}." + ) + assert result[0].resource_id == "fn-vpc" + assert result[0].resource_name == "fn-vpc" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_without_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-public", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Cloud Function fn-public is not connected to any VPC network." + ) + assert result[0].resource_id == "fn-public" + assert result[0].resource_name == "fn-public" + assert result[0].location == GCP_US_CENTER1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_function_with_empty_vpc_connector(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-empty", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="ACTIVE", + vpc_connector="", + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + + def test_inactive_function_skipped(self): + cloudfunction_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=cloudfunction_client, + ), + ): + from prowler.providers.gcp.services.cloudfunction.cloudfunction_function_inside_vpc.cloudfunction_function_inside_vpc import ( + cloudfunction_function_inside_vpc, + ) + from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + Function, + ) + + cloudfunction_client.functions = [ + Function( + name="fn-deploy", + project_id=GCP_PROJECT_ID, + location=GCP_US_CENTER1_LOCATION, + state="DEPLOYING", + vpc_connector=None, + ) + ] + + check = cloudfunction_function_inside_vpc() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py new file mode 100644 index 0000000000..82f77a939f --- /dev/null +++ b/tests/providers/gcp/services/cloudfunction/cloudfunction_service_test.py @@ -0,0 +1,102 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.cloudfunction.cloudfunction_service import ( + CloudFunction, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + +_LOCATION_ID = "us-central1" +_FUNCTION_NAME = "my-function" +_CONNECTOR = ( + f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/connectors/my-connector" +) + + +def _make_cloudfunction_client(functions_list): + """Return a mock GCP API client for the Cloud Functions v2 service.""" + client = MagicMock() + + client.projects().locations().list().execute.return_value = { + "locations": [{"locationId": _LOCATION_ID}] + } + client.projects().locations().list_next.return_value = None + + client.projects().locations().functions().list().execute.return_value = { + "functions": functions_list + } + client.projects().locations().functions().list_next.return_value = None + + return client + + +class TestCloudFunctionService: + def test_get_functions_with_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/{_FUNCTION_NAME}", + "state": "ACTIVE", + "serviceConfig": { + "vpcConnector": _CONNECTOR, + }, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == _FUNCTION_NAME + assert fn.project_id == GCP_PROJECT_ID + assert fn.location == _LOCATION_ID + assert fn.state == "ACTIVE" + assert fn.vpc_connector == _CONNECTOR + + def test_get_functions_without_vpc_connector(self): + def mock_api_client(*args, **kwargs): + return _make_cloudfunction_client( + functions_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/locations/{_LOCATION_ID}/functions/no-vpc-func", + "state": "ACTIVE", + "serviceConfig": {}, + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + cf_client = CloudFunction( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(cf_client.functions) == 1 + fn = cf_client.functions[0] + assert fn.name == "no-vpc-func" + assert fn.vpc_connector is None diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py new file mode 100644 index 0000000000..82c49fb89e --- /dev/null +++ b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_EU1_LOCATION, + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + + +class Test_cloudsql_instance_high_availability_enabled: + """Tests for the cloudsql_instance_high_availability_enabled check.""" + + def test_no_instances(self): + """No Cloud SQL instances → no findings.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + + cloudsql_client.instances = [] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_ha_enabled(self): + """A REGIONAL primary instance → PASS.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-ha", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="REGIONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "db-ha" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_ha_disabled(self): + """A ZONAL primary instance → FAIL with current availability in status_extended.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-zonal", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended + assert result[0].resource_id == "db-zonal" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_read_replica_skipped(self): + """Read replicas (instance_type != CLOUD_SQL_INSTANCE) are skipped.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-replica", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + instance_type="READ_REPLICA_INSTANCE", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_default_availability_type_fails(self): + """An instance missing availabilityType defaults to ZONAL (service layer) and must FAIL.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-default", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + # availability_type omitted → model default "ZONAL" + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended diff --git a/tests/providers/gcp/services/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/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/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/AGENTS.md b/ui/AGENTS.md index c9adca0961..af04c838f8 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 57d0427dc9..1baddf169a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,7 +2,32 @@ 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 + +- Radio button no longer shifts vertically when selected [(#11608)](https://github.com/prowler-cloud/prowler/pull/11608) + +### 🔐 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) + +--- + +## [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) + +--- + +## [1.30.0] (Prowler v5.30.0) ### 🚀 Added @@ -12,7 +37,11 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 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 [(#4554)](https://github.com/prowler-cloud/prowler-cloud/pull/4554) +- 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) --- diff --git a/ui/Dockerfile b/ui/Dockerfile index 047df1171b..6cf930ee5e 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 diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 58e7c5b5a0..e06b06d29d 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -73,12 +73,21 @@ 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, 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/scans/scans.ts b/ui/actions/scans/scans.ts index 318004ea96..fb32a6286e 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -15,6 +15,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 +65,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(), { 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 1dd09f6fe3..6d5ebc945d 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 205d6530e9..19f449c159 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(() => ({ @@ -94,12 +99,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", () => ({ @@ -257,6 +287,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(); @@ -279,6 +341,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 2e76121f28..92f11631ad 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/scan-list-table.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/scan-list-table.test.tsx index 26f88dba3f..fc2704c1d4 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/shadcn/table", () => ({ data, metadata, controlledPage, + getRowAttributes, }: { columns: Array<{ id?: string; @@ -74,6 +75,10 @@ vi.mock("@/components/shadcn/table", () => ({ }; }; controlledPage: number; + getRowAttributes?: (row: { + index: number; + original: AttackPathScan; + }) => Record; }) => (
{metadata.pagination.count} Total Entries @@ -95,8 +100,8 @@ vi.mock("@/components/shadcn/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 2e946bbe45..5f894580e0 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..39515698a1 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-attack-path-scans.ts @@ -0,0 +1,81 @@ +"use client"; + +import { 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; + refreshScans: () => 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 refreshScans = async () => { + try { + const scansData = await getAttackPathScans(); + if (scansData?.data) { + setScans(scansData.data); + } + } catch (error) { + console.error("Failed to refresh scans:", error); + } + }; + + useMountEffect(() => { + let active = true; + + const loadScans = async () => { + setScansLoading(true); + try { + const scansData = await getAttackPathScans(); + const nextScans = scansData?.data ?? []; + if (!active) return; + setScans(nextScans); + if (!nextScans.some((scan) => scan.attributes.graph_data_ready)) { + onNoReadyScan?.(); + } + } catch (error) { + if (!active) return; + console.error("Failed to load scans:", error); + setScans([]); + onNoReadyScan?.(); + } finally { + if (active) setScansLoading(false); + } + }; + + void loadScans(); + + return () => { + active = false; + }; + }); + + return { scans, scansLoading, refreshScans }; +} 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 90dc44ef5e..08d61df235 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 @@ -2,7 +2,7 @@ import { ArrowLeft, Info, Maximize2 } from "lucide-react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; import { FormProvider } from "react-hook-form"; @@ -10,11 +10,11 @@ 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 { @@ -32,10 +32,19 @@ import { DialogTitle, DialogTrigger, } from "@/components/shadcn/dialog"; +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"; @@ -53,23 +62,30 @@ import { ScanListTable, } from "./_components"; 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"; -/** - * 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, refreshScans } = 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 +97,62 @@ 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, { + enabled: onboardingEnabled && !scansLoading && 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,60 +160,22 @@ 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); - } - }; - - 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, ); - // Detect if the selected scan is showing data from a previous cycle const selectedScan = scans.find((scan) => scan.id === scanId); const isViewingPreviousCycleData = selectedScan && 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 +235,6 @@ export default function AttackPathsPage() { return; } - // Validate form before executing query const isValid = await queryBuilder.form.trigger(); if (!isValid) { showErrorToast( @@ -215,6 +244,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 +289,6 @@ export default function AttackPathsPage() { variant: "default", }); - // Scroll to graph after successful query execution setTimeout(() => { graphContainerRef.current?.scrollIntoView({ behavior: "smooth", @@ -297,13 +328,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 +395,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. @@ -390,27 +422,27 @@ export default function AttackPathsPage() {

Loading scans...

- ) : scans.length === 0 ? ( - - - No scans available - - - You need to run a scan before you can analyze attack paths.{" "} - - Go to Scan Jobs - - - - + ) : hasNoScans ? ( +
+ + + No scans available + + + You need to run a scan before you can analyze attack paths.{" "} + + Go to Scan Jobs + + + + +
) : ( <> - {/* Scans Table */} Loading scans...
}> - {/* Banner: viewing data from a previous scan cycle */} {isViewingPreviousCycleData && ( @@ -425,7 +457,6 @@ export default function AttackPathsPage() { )} - {/* Query Builder Section - shown only after selecting a scan */} {scanId && (
{queriesLoading ? ( @@ -438,11 +469,13 @@ export default function AttackPathsPage() { ) : ( <> - +
+ +
{queryBuilder.selectedQueryData && ( -
+
)} - {/* Graph Visualization (Full Width) */} {(graphState.loading || (graphState.data && graphState.data.nodes && @@ -488,7 +523,6 @@ export default function AttackPathsPage() { graphState.data.nodes && graphState.data.nodes.length > 0 ? ( <> - {/* Info message and controls */}
{graphState.isFilteredView ? (
@@ -537,7 +571,6 @@ export default function AttackPathsPage() {
)} - {/* Graph controls and fullscreen button together */}
graphRef.current?.zoomIn()} @@ -546,7 +579,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 2f0d0c9539..0ab889b7fe 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 d6bc0b22b9..de17ac4a37 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -86,7 +86,7 @@ export default async function ComplianceDetail({ "filter[scan_id]": selectedScanId ?? undefined, }, }), - getComplianceAttributes(complianceId), + getComplianceAttributes(complianceId, selectedScanId ?? undefined), selectedScanId ? getScan(selectedScanId, { include: "provider" }) : Promise.resolve(null), diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 12454eccd8..f2aae88c23 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 04023a88a0..341ca6b280 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 f5ac7f09e6..b789b10a31 100644 --- a/ui/app/(prowler)/layout.tsx +++ b/ui/app/(prowler)/layout.tsx @@ -2,16 +2,24 @@ 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 { getScansByState } from "@/actions/scans/scans"; import { getProviders } from "@/actions/providers"; import MainLayout from "@/components/layout/main-layout/main-layout"; +import { + OnboardingCheckpointWatcher, + OnboardingGate, + OnboardingSequenceBanner, +} from "@/components/onboarding"; import { NavigationProgress } from "@/components/shadcn/navigation-progress"; import { Toaster } from "@/components/shadcn/toast"; import { fontMono, 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,8 +49,30 @@ 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 ( @@ -56,8 +86,22 @@ export default async function RootLayout({ )} > - - + {/* 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 7fcfcdf02f..dc42479305 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)/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 9ecb439524..e08faa2951 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -1,8 +1,10 @@ +import { redirect } from "next/navigation"; import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { auth } from "@/auth.config"; +import { PageReady } from "@/components/onboarding"; import { getScanJobsTab, getScanJobsTabFilters, @@ -69,19 +71,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. */} + + + ) : ( = ({ }; 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-overview-grid.tsx b/ui/components/compliance/compliance-overview-grid.tsx index 71933587fb..5900b23a86 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/shadcn/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/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index cc4a685a3d..4e2e86048d 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 2b5bc52176..3d31ac0fb2 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/shadcn/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: () => (